Compare commits
3 Commits
3af622f53d
...
5668a63370
| Author | SHA1 | Date | |
|---|---|---|---|
| 5668a63370 | |||
| 1465016b56 | |||
| 906e37e845 |
+1
-1
@@ -29,7 +29,7 @@ try {
|
||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
||||
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
phone VARCHAR(50),
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
|
||||
Generated
+586
-281
File diff suppressed because it is too large
Load Diff
+7
-2
@@ -4,9 +4,10 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||
"dev:signaling": "node signaling-server.mjs",
|
||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT -c cyan,magenta,yellow,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "set FX_PROFILE=C:\\Users\\USER-PC\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\h8p11vlj.default-release && cd browser-use-service && python main.py",
|
||||
@@ -32,6 +33,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.108.2",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -47,6 +50,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
+1
-1
@@ -446,7 +446,7 @@ async fn main() {
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { createServer } from "http"
|
||||
import { Server } from "socket.io"
|
||||
import { SignJWT, jwtVerify } from "jose"
|
||||
import pg from "pg"
|
||||
|
||||
const PORT = process.env.SIGNALING_PORT || 3007
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||
|
||||
const httpServer = createServer()
|
||||
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
||||
|
||||
const onlineUsers = new Map()
|
||||
const rooms = new Map()
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token
|
||||
if (token) {
|
||||
jwtVerify(token, JWT_SECRET)
|
||||
.then(({ payload }) => {
|
||||
socket.data.userId = payload.userId
|
||||
socket.data.role = payload.role
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
socket.data.userId = null
|
||||
socket.data.role = null
|
||||
next()
|
||||
})
|
||||
} else {
|
||||
socket.data.userId = null
|
||||
socket.data.role = null
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
const userId = socket.data.userId
|
||||
|
||||
if (userId) {
|
||||
onlineUsers.set(userId, socket.id)
|
||||
socket.broadcast.emit("user:online", { userId })
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
socket.on("user:lookup", async ({ phone }, callback) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
if (result.rows.length > 0) {
|
||||
const user = result.rows[0]
|
||||
callback({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
online: onlineUsers.has(user.id),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
callback({ found: false })
|
||||
}
|
||||
} catch {
|
||||
callback({ found: false, error: "Lookup failed" })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
let info = callerInfo
|
||||
if (!info) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, username, first_name, last_name, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
if (result.rows.length > 0) {
|
||||
const u = result.rows[0]
|
||||
info = {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
firstName: u.first_name,
|
||||
lastName: u.last_name,
|
||||
avatar: u.avatar_url,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
io.to(targetSocketId).emit("call:incoming", {
|
||||
from: userId,
|
||||
sdp,
|
||||
callerInfo: info,
|
||||
})
|
||||
} else {
|
||||
socket.emit("call:user-offline", { userId: to })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:answer", ({ to, sdp }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:end", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:ended", { from: userId })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:reject", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:rejected", { from: userId })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:busy", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:busy", { from: userId })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
socket.on("room:join", ({ roomId, displayName }) => {
|
||||
const participantId = userId || `anon-${socket.id}`
|
||||
const participantLabel = displayName || participantId
|
||||
|
||||
if (!rooms.has(roomId)) {
|
||||
rooms.set(roomId, [])
|
||||
}
|
||||
const participants = rooms.get(roomId)
|
||||
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
|
||||
socket.join(roomId)
|
||||
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
|
||||
if (participants.length === 1) {
|
||||
socket.emit("room:waiting", { roomId, participants: list })
|
||||
} else if (participants.length === 2) {
|
||||
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
|
||||
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
|
||||
} else {
|
||||
socket.emit("room:participant-joined", { roomId, participants: list })
|
||||
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:offer", ({ roomId, sdp }) => {
|
||||
socket.to(roomId).emit("room:offer", { sdp })
|
||||
})
|
||||
|
||||
socket.on("room:answer", ({ roomId, sdp }) => {
|
||||
socket.to(roomId).emit("room:answer", { sdp })
|
||||
})
|
||||
|
||||
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
||||
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
||||
})
|
||||
|
||||
socket.on("room:leave", ({ roomId }) => {
|
||||
socket.leave(roomId)
|
||||
const participants = rooms.get(roomId)
|
||||
if (participants) {
|
||||
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||
if (idx !== -1) participants.splice(idx, 1)
|
||||
if (participants.length === 0) {
|
||||
rooms.delete(roomId)
|
||||
} else {
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
for (const [roomId, participants] of rooms) {
|
||||
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||
if (idx !== -1) {
|
||||
participants.splice(idx, 1)
|
||||
if (participants.length === 0) {
|
||||
rooms.delete(roomId)
|
||||
} else {
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userId) {
|
||||
onlineUsers.delete(userId)
|
||||
socket.broadcast.emit("user:offline", { userId })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`[signaling] server running on port ${PORT}`)
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
CornerDownRight, Forward, Pencil, Download,
|
||||
} from "lucide-react"
|
||||
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { toast } from "sonner"
|
||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
|
||||
@@ -124,6 +125,7 @@ export default function ChatsPage() {
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -642,12 +644,9 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
@@ -995,6 +994,8 @@ export default function ChatsPage() {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get("session")?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ token })
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export async function GET() {
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
@@ -39,6 +40,7 @@ export async function GET() {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
phone: row.other_user_phone || "",
|
||||
avatar: avatarSvgUrl(row.other_user_name),
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
phone: other.phone || "",
|
||||
avatar: avatarSvgUrl(other.name),
|
||||
},
|
||||
lastMessage: "",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
)
|
||||
|
||||
const origin = request.headers.get("origin") || "http://localhost:3000"
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ found: false })
|
||||
}
|
||||
|
||||
const user = result.rows[0]
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use, useCallback } from "react"
|
||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||
const { roomId } = use(params)
|
||||
|
||||
const {
|
||||
callState,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
participants,
|
||||
joinRoom,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
formatDuration,
|
||||
} = useWebRTCCall({ anonymous: true })
|
||||
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [micError, setMicError] = useState<string | null>(null)
|
||||
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!isReady) setConnectionTimeout(true)
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isReady])
|
||||
|
||||
const handleJoin = useCallback(async () => {
|
||||
if (!displayName.trim()) return
|
||||
setMicError(null)
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
joinRoom(roomId, displayName.trim() || "Anonymous")
|
||||
setJoined(true)
|
||||
} catch {
|
||||
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
|
||||
}
|
||||
}, [displayName, roomId, joinRoom])
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
endCall()
|
||||
}, [endCall])
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{connectionTimeout ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
</>
|
||||
) : !isReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||
{micError && (
|
||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||
placeholder="Enter your name"
|
||||
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-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!displayName.trim()}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Join Call
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{callState === "calling" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "waiting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "participant_joined" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connecting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connected" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(callState === "ended" || callState === "idle") && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||
{callDuration > 0 && (
|
||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { query } from "@/lib/db"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function ErrorPage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JoinPage({ params }: Props) {
|
||||
const { token } = await params
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
if (UUID_REGEX.test(token)) {
|
||||
redirect(`/call/${token}`)
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||
[token],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return <ErrorPage message="This call link is invalid." />
|
||||
}
|
||||
|
||||
const invite = result.rows[0]
|
||||
|
||||
if (new Date(invite.expires_at) <= new Date()) {
|
||||
return <ErrorPage message="This call has expired." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p className="text-[#888888] text-sm mb-6">
|
||||
Someone wants to connect with you on our platform.
|
||||
</p>
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] mb-6">
|
||||
Create an account to start making free voice calls to this person and others on the platform.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
@@ -14,7 +14,8 @@ const waves = [
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -215,7 +216,7 @@ export default function LoginPage() {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
window.location.href = redirectTo
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Phone, X, Copy, MessageSquare } 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
|
||||
}
|
||||
|
||||
function formatPhoneForWhatsApp(phone: string): string {
|
||||
const digits = phone.replace(/[^0-9]/g, "")
|
||||
if (!digits) return ""
|
||||
if (digits.startsWith("27")) return digits
|
||||
if (digits.startsWith("0")) return "27" + digits.slice(1)
|
||||
return digits
|
||||
}
|
||||
|
||||
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
const [contacts, setContacts] = useState<Contact[]>([])
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
const [contactsError, setContactsError] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [phoneNumber, setPhoneNumber] = useState("")
|
||||
const [phoneError, setPhoneError] = useState(false)
|
||||
|
||||
const [callLink, setCallLink] = useState<string | null>(null)
|
||||
const [shareSent, setShareSent] = useState(false)
|
||||
const [shareError, setShareError] = useState<string | null>(null)
|
||||
const [targetPhone, setTargetPhone] = useState<string>("")
|
||||
const [showWaUrl, setShowWaUrl] = useState<string | null>(null)
|
||||
|
||||
const { createRoom } = useWebRTCCall()
|
||||
|
||||
const handleCall = useCallback((phone?: string) => {
|
||||
setShareSent(false)
|
||||
setShareError(null)
|
||||
const { roomId, link } = createRoom()
|
||||
setCallLink(link)
|
||||
setTargetPhone(phone || "")
|
||||
console.log("[call] room created, roomId:", roomId)
|
||||
console.log("[call] join link:", link)
|
||||
console.log("[call] target phone:", phone || "none")
|
||||
}, [createRoom])
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
if (!callLink) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(callLink)
|
||||
setShareSent(true)
|
||||
setShareError(null)
|
||||
console.log("[share] link copied")
|
||||
} catch {
|
||||
setShareError("Failed to copy URL")
|
||||
console.error("[share] copy failed")
|
||||
}
|
||||
}, [callLink])
|
||||
|
||||
const sendWhatsApp = useCallback(() => {
|
||||
if (!callLink) return
|
||||
|
||||
const originalPhone = targetPhone
|
||||
const formattedPhone = formatPhoneForWhatsApp(originalPhone)
|
||||
const generatedCallLink = callLink
|
||||
|
||||
console.log("[wa] original phone:", originalPhone)
|
||||
console.log("[wa] formatted phone:", formattedPhone)
|
||||
console.log("[wa] call link:", generatedCallLink)
|
||||
|
||||
if (!formattedPhone) {
|
||||
setShareError("Invalid phone number.")
|
||||
console.error("[wa] invalid phone number")
|
||||
return
|
||||
}
|
||||
|
||||
const msg = encodeURIComponent(`Hi! Join me on this link so we can start our call:\n\n${generatedCallLink}`)
|
||||
const waUrl = `https://wa.me/${formattedPhone}?text=${msg}`
|
||||
|
||||
console.log("[wa] WhatsApp URL:", waUrl)
|
||||
setShowWaUrl(waUrl)
|
||||
|
||||
const w = window.open(waUrl, "_blank")
|
||||
if (!w || w.closed) {
|
||||
setShareError("Could not open WhatsApp. Please check your browser allows popups.")
|
||||
console.error("[wa] failed: window.open returned null or closed")
|
||||
} else {
|
||||
setShareSent(true)
|
||||
setShareError(null)
|
||||
console.log("[wa] success: WhatsApp opened")
|
||||
}
|
||||
}, [callLink, targetPhone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("")
|
||||
setPhoneNumber("")
|
||||
setPhoneError(false)
|
||||
setCallLink(null)
|
||||
setShareSent(false)
|
||||
setShareError(null)
|
||||
setTargetPhone("")
|
||||
setShowWaUrl(null)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const FALLBACK_CONTACTS: Contact[] = [
|
||||
{ id: "fallback-dillen", name: "Dillen", phone: "0799158142", avatar_url: null },
|
||||
{ id: "fallback-ewan", name: "Ewan", phone: "0845172665", avatar_url: null },
|
||||
{ id: "fallback-caitlin", name: "Caitlin", phone: "0612729281", avatar_url: null },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) 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 || []), ...FALLBACK_CONTACTS])
|
||||
} catch {
|
||||
setContacts(FALLBACK_CONTACTS)
|
||||
} 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 (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{callLink ? (
|
||||
shareSent ? (
|
||||
<>
|
||||
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
The recipient must receive and open the call link.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShareSent(false); setShareError(null) }}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={callLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||
>
|
||||
Open Call
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
Select a method to send the call link
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
{showWaUrl && (
|
||||
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
|
||||
{showWaUrl}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 mb-4">
|
||||
<button
|
||||
onClick={sendWhatsApp}
|
||||
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#1da851] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
WhatsApp
|
||||
</button>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{shareError && (
|
||||
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
CONTACTS
|
||||
</p>
|
||||
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||
{contactsLoading && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||
)}
|
||||
{contactsError && (
|
||||
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
alt={contact.name}
|
||||
className="w-full h-full rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
contact.name.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
||||
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCall(contact.phone)}
|
||||
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
DIAL A NUMBER
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneNumber}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
const trimmed = phoneNumber.trim()
|
||||
if (!trimmed) {
|
||||
setPhoneError(true)
|
||||
return
|
||||
}
|
||||
setPhoneError(false)
|
||||
handleCall(trimmed)
|
||||
}}
|
||||
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call Now
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export const users: User[] = [
|
||||
id: "u-super",
|
||||
name: "Jason Oosthuizen",
|
||||
email: "jason@coastalit.com",
|
||||
phone: "+27 82 555 0100",
|
||||
role: "super_admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
|
||||
@@ -14,6 +15,7 @@ export const users: User[] = [
|
||||
id: "u1",
|
||||
name: "Sarah Chen",
|
||||
email: "SarahChen@coastit.co.za",
|
||||
phone: "+27 72 555 0101",
|
||||
role: "admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
||||
@@ -23,6 +25,7 @@ export const users: User[] = [
|
||||
id: "u2",
|
||||
name: "Marcus Johnson",
|
||||
email: "marcus@coastalit.com",
|
||||
phone: "+27 62 555 0102",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
|
||||
@@ -32,6 +35,7 @@ export const users: User[] = [
|
||||
id: "u3",
|
||||
name: "Emily Rodriguez",
|
||||
email: "emily@coastalit.com",
|
||||
phone: "+27 73 555 0103",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
|
||||
@@ -41,6 +45,7 @@ export const users: User[] = [
|
||||
id: "u4",
|
||||
name: "David Kim",
|
||||
email: "david@coastalit.com",
|
||||
phone: "+27 64 555 0104",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
|
||||
@@ -50,6 +55,7 @@ export const users: User[] = [
|
||||
id: "u5",
|
||||
name: "Jessica Patel",
|
||||
email: "jessica@coastalit.com",
|
||||
phone: "+27 82 555 0105",
|
||||
role: "sales",
|
||||
active: false,
|
||||
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
|
||||
@@ -59,6 +65,7 @@ export const users: User[] = [
|
||||
id: "u6",
|
||||
name: "Alex Thompson",
|
||||
email: "alex@coastalit.com",
|
||||
phone: "+27 72 555 0106",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
|
||||
@@ -68,6 +75,7 @@ export const users: User[] = [
|
||||
id: "u7",
|
||||
name: "Rachel Williams",
|
||||
email: "rachel@coastalit.com",
|
||||
phone: "+27 64 555 0107",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
|
||||
@@ -77,6 +85,7 @@ export const users: User[] = [
|
||||
id: "u8",
|
||||
name: "Tom Nakamura",
|
||||
email: "tom@coastalit.com",
|
||||
phone: "+27 83 555 0108",
|
||||
role: "admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { io, Socket } from "socket.io-client"
|
||||
|
||||
const STUN_SERVERS = {
|
||||
iceServers: [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
{ urls: "stun:stun1.l.google.com:19302" },
|
||||
],
|
||||
}
|
||||
|
||||
const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007"
|
||||
|
||||
export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined"
|
||||
|
||||
export interface PlatformUser {
|
||||
id: string
|
||||
username: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface CallerInfo {
|
||||
id: string
|
||||
username: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useWebRTCCall(options?: { anonymous?: boolean }) {
|
||||
const isAnonymous = options?.anonymous ?? false
|
||||
|
||||
const [callState, setCallState] = useState<CallState>("idle")
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
|
||||
const [callDuration, setCallDuration] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
|
||||
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
|
||||
const [calleeNumber, setCalleeNumber] = useState<string>("")
|
||||
const [tokenLoading, setTokenLoading] = useState(true)
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null)
|
||||
const localStreamRef = useRef<MediaStream | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
|
||||
const roomIdRef = useRef<string | null>(null)
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
setCallDuration(0)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = setInterval(() => {
|
||||
setCallDuration((prev) => prev + 1)
|
||||
}, 1000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let socket: Socket | null = null
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
let authToken: string | undefined
|
||||
|
||||
if (!isAnonymous) {
|
||||
const res = await fetch("/api/auth/jwt")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
authToken = data.token
|
||||
}
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
socket = io(SIGNALING_URL, {
|
||||
auth: authToken ? { token: authToken } : undefined,
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
|
||||
socket.on("connect", () => setIsReady(true))
|
||||
socket.on("disconnect", () => setIsReady(false))
|
||||
|
||||
if (!isAnonymous) {
|
||||
socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => {
|
||||
setCallerInfo(info)
|
||||
setCallState("ringing")
|
||||
pendingCallRef.current = { to: from, sdp }
|
||||
})
|
||||
|
||||
socket.on("call:answered", async ({ from, sdp }) => {
|
||||
setCallState("connecting")
|
||||
if (pcRef.current && sdp) {
|
||||
try {
|
||||
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ice-candidate", async ({ from, candidate }) => {
|
||||
if (pcRef.current && candidate) {
|
||||
try {
|
||||
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ended", ({ from }) => {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:rejected", ({ from }) => {
|
||||
setCallState("rejected")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:busy", ({ from }) => {
|
||||
setCallState("busy")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:user-offline", ({ userId }) => {
|
||||
setCallState("offline")
|
||||
cleanupCall()
|
||||
})
|
||||
}
|
||||
|
||||
socket.on("room:waiting", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("waiting")
|
||||
})
|
||||
|
||||
socket.on("room:participant-joined", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("participant_joined")
|
||||
})
|
||||
|
||||
socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("participant_joined")
|
||||
try {
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && roomIdRef.current) {
|
||||
socket?.emit("room:ice-candidate", {
|
||||
roomId: roomIdRef.current,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
pcRef.current = pc
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
setCallState("connecting")
|
||||
socket?.emit("room:offer", { roomId, sdp: pc.localDescription })
|
||||
} catch (e) {
|
||||
setError("Failed to start call")
|
||||
cleanupCall()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:offer", async ({ sdp }) => {
|
||||
try {
|
||||
setCallState("connecting")
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && roomIdRef.current) {
|
||||
socket?.emit("room:ice-candidate", {
|
||||
roomId: roomIdRef.current,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
pcRef.current = pc
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription })
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch (e) {
|
||||
setError("Failed to join call")
|
||||
cleanupCall()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:answer", async ({ sdp }) => {
|
||||
if (pcRef.current) {
|
||||
try {
|
||||
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:ice-candidate", async ({ candidate }) => {
|
||||
if (pcRef.current) {
|
||||
try {
|
||||
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:participant-left", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socketRef.current = socket
|
||||
} finally {
|
||||
setTokenLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (socket) socket.disconnect()
|
||||
cleanupCall()
|
||||
}
|
||||
}, [isAnonymous])
|
||||
|
||||
const cleanupCall = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close()
|
||||
pcRef.current = null
|
||||
}
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach((t) => t.stop())
|
||||
localStreamRef.current = null
|
||||
}
|
||||
setRemoteStream(null)
|
||||
setIsMuted(false)
|
||||
setIsSpeakerOn(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.srcObject = null
|
||||
audioRef.current = null
|
||||
}
|
||||
setCallDuration(0)
|
||||
pendingCallRef.current = null
|
||||
roomIdRef.current = null
|
||||
setParticipants([])
|
||||
}, [])
|
||||
|
||||
const createPeerConnection = useCallback(async () => {
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:ice-candidate", {
|
||||
to: pendingCallRef.current.to,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
} catch {
|
||||
setError("Microphone access denied")
|
||||
}
|
||||
|
||||
pcRef.current = pc
|
||||
return pc
|
||||
}, [cleanupCall])
|
||||
|
||||
const lookupUser = useCallback(async (phone: string): Promise<PlatformUser | null> => {
|
||||
return new Promise((resolve) => {
|
||||
socketRef.current?.emit("user:lookup", { phone }, (result: any) => {
|
||||
if (result?.found) {
|
||||
resolve(result.user)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const makeCall = useCallback(async (phoneNumber: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
setCalleeNumber(phoneNumber)
|
||||
|
||||
const user = await lookupUser(phoneNumber)
|
||||
if (!user) {
|
||||
setCallState("missed")
|
||||
setError("Not on Platform")
|
||||
return
|
||||
}
|
||||
if (!user.online) {
|
||||
setCallState("offline")
|
||||
setError("User is offline")
|
||||
return
|
||||
}
|
||||
|
||||
const pc = await createPeerConnection()
|
||||
setCallState("calling")
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
|
||||
socketRef.current?.emit("call:offer", {
|
||||
to: user.id,
|
||||
sdp: pc.localDescription,
|
||||
callerInfo: null,
|
||||
})
|
||||
pendingCallRef.current = { to: user.id, sdp: null }
|
||||
|
||||
setCallState("ringing")
|
||||
} catch {
|
||||
setError("Failed to start call")
|
||||
cleanupCall()
|
||||
}
|
||||
}, [createPeerConnection, lookupUser, cleanupCall])
|
||||
|
||||
const acceptCall = useCallback(async () => {
|
||||
if (!pendingCallRef.current) return
|
||||
setCallState("connecting")
|
||||
|
||||
try {
|
||||
const pc = await createPeerConnection()
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp))
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
|
||||
socketRef.current?.emit("call:answer", {
|
||||
to: pendingCallRef.current.to,
|
||||
sdp: pc.localDescription,
|
||||
})
|
||||
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch {
|
||||
setError("Failed to accept call")
|
||||
cleanupCall()
|
||||
}
|
||||
}, [createPeerConnection, cleanupCall, startTimer])
|
||||
|
||||
const rejectCall = useCallback(() => {
|
||||
if (pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||
}
|
||||
setCallState("idle")
|
||||
cleanupCall()
|
||||
}, [cleanupCall])
|
||||
|
||||
const endCall = useCallback(() => {
|
||||
if (roomIdRef.current) {
|
||||
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||
}
|
||||
if (!isAnonymous) {
|
||||
if (callState === "ringing" && pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||
}
|
||||
if (callState === "connected" || callState === "connecting" || callState === "calling") {
|
||||
const to = pendingCallRef.current?.to
|
||||
if (to) {
|
||||
socketRef.current?.emit("call:end", { to })
|
||||
}
|
||||
}
|
||||
}
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}, [callState, cleanupCall, isAnonymous])
|
||||
|
||||
const toggleSpeaker = useCallback(() => {
|
||||
setIsSpeakerOn((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = isMuted
|
||||
})
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
}, [isMuted])
|
||||
|
||||
const formatDuration = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, "0")
|
||||
const s = (seconds % 60).toString().padStart(2, "0")
|
||||
return `${m}:${s}`
|
||||
}, [])
|
||||
|
||||
const createRoom = useCallback(() => {
|
||||
const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14)
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000")
|
||||
const link = `${baseUrl}/join/${roomId}`
|
||||
return { roomId, link }
|
||||
}, [])
|
||||
|
||||
const joinRoom = useCallback((roomId: string, displayName?: string) => {
|
||||
roomIdRef.current = roomId
|
||||
setCallState("calling")
|
||||
socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" })
|
||||
}, [])
|
||||
|
||||
const leaveRoom = useCallback(() => {
|
||||
if (roomIdRef.current) {
|
||||
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||
}
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}, [cleanupCall])
|
||||
|
||||
return {
|
||||
callState,
|
||||
isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined",
|
||||
isIncoming: callState === "ringing" && !!callerInfo,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
remoteStream,
|
||||
callerInfo,
|
||||
calleeNumber,
|
||||
participants,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
makeCall,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
lookupUser,
|
||||
formatDuration,
|
||||
tokenLoading,
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -17,6 +17,7 @@ export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
|
||||
u.is_active, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
|
||||
id: dbUser.id as string,
|
||||
username: dbUser.username as string,
|
||||
email: dbUser.email as string,
|
||||
phone: (dbUser.phone as string) || null,
|
||||
firstName: dbUser.first_name as string,
|
||||
lastName: dbUser.last_name as string,
|
||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
"/join",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/_next/static",
|
||||
|
||||
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
id: u.id,
|
||||
name: `${u.firstName} ${u.lastName}`,
|
||||
email: u.email,
|
||||
phone: u.phone || "",
|
||||
role: u.role,
|
||||
active: true,
|
||||
avatar: u.avatar,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
avatar: string;
|
||||
|
||||
Reference in New Issue
Block a user