Compare commits

...

3 Commits

Author SHA1 Message Date
Hannah_Bagga 5668a63370 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-25 15:50:35 +02:00
Hannah_Bagga 1465016b56 ..... 2026-06-25 15:15:35 +02:00
Hannah_Bagga 906e37e845 .... 2026-06-25 15:15:15 +02:00
23 changed files with 2125 additions and 299 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ try {
const PORT = parseInt(process.env.AI_PORT || "3001", 10) const PORT = parseInt(process.env.AI_PORT || "3001", 10)
const HOST = process.env.AI_HOST || "0.0.0.0" const HOST = process.env.AI_HOST || "0.0.0.0"
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434" 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 DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl") 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") const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
+22
View File
@@ -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);
+10
View File
@@ -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);
+586 -281
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -4,9 +4,10 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "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: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: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: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", "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-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-table": "^8.20.6", "@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
@@ -47,6 +50,8 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.54.2", "react-hook-form": "^7.54.2",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^1.7.4", "sonner": "^1.7.4",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
+1 -1
View File
@@ -446,7 +446,7 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 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 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 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 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"); let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
+221
View File
@@ -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}`)
})
+6 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { 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, Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, CornerDownRight, Forward, Pencil, Download,
} from "lucide-react" } from "lucide-react"
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider" import { useUser } from "@/providers/user-provider"
import { toast } from "sonner" import { toast } from "sonner"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import data from "@emoji-mart/data" import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react" import Picker from "@emoji-mart/react"
@@ -124,6 +125,7 @@ export default function ChatsPage() {
const [searchResults, setSearchResults] = useState<any[]>([]) const [searchResults, setSearchResults] = useState<any[]>([])
const [searchingUsers, setSearchingUsers] = useState(false) const [searchingUsers, setSearchingUsers] = useState(false)
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map()) const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null) const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -642,12 +644,9 @@ export default function ChatsPage() {
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <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" /> <Phone className="h-4 w-4" />
</Button> </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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8">
@@ -995,6 +994,8 @@ export default function ChatsPage() {
</ScrollArea> </ScrollArea>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
</div> </div>
) )
} }
+11
View File
@@ -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 })
}
+8 -5
View File
@@ -13,10 +13,11 @@ export async function GET() {
c.id, c.id,
c.updated_at, c.updated_at,
cp_me.last_read_at, cp_me.last_read_at,
u.id AS other_user_id, u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name, u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email, u.email AS other_user_email,
u.avatar_url AS other_user_avatar_url, 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 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, (SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread (SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
@@ -39,6 +40,7 @@ export async function GET() {
id: row.other_user_id, id: row.other_user_id,
name: row.other_user_name, name: row.other_user_name,
email: row.other_user_email, email: row.other_user_email,
phone: row.other_user_phone || "",
avatar: avatarSvgUrl(row.other_user_name), avatar: avatarSvgUrl(row.other_user_name),
}, },
lastMessage: row.last_message || "", lastMessage: row.last_message || "",
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
) )
const otherUser = await query( 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`, FROM users WHERE id = $1`,
[userId], [userId],
) )
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
id: other.id, id: other.id,
name: other.name, name: other.name,
email: other.email, email: other.email,
phone: other.phone || "",
avatar: avatarSvgUrl(other.name), avatar: avatarSvgUrl(other.name),
}, },
lastMessage: "", lastMessage: "",
+25
View File
@@ -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 })
}
}
+38
View File
@@ -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 })
}
}
+226
View File
@@ -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>
)
}
+69
View File
@@ -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&apos;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>
)
}
+4 -3
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation" import { useSearchParams } from "next/navigation"
import { COMPANY_NAME } from "@/lib/constants" import { COMPANY_NAME } from "@/lib/constants"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
@@ -14,7 +14,8 @@ const waves = [
] ]
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/dashboard"
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -215,7 +216,7 @@ export default function LoginPage() {
}) })
if (res.ok) { if (res.ok) {
router.push("/dashboard") window.location.href = redirectTo
} else { } else {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
+350
View File
@@ -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>
)
}
+9
View File
@@ -5,6 +5,7 @@ export const users: User[] = [
id: "u-super", id: "u-super",
name: "Jason Oosthuizen", name: "Jason Oosthuizen",
email: "jason@coastalit.com", email: "jason@coastalit.com",
phone: "+27 82 555 0100",
role: "super_admin", role: "super_admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128", 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", id: "u1",
name: "Sarah Chen", name: "Sarah Chen",
email: "SarahChen@coastit.co.za", email: "SarahChen@coastit.co.za",
phone: "+27 72 555 0101",
role: "admin", role: "admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128", 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", id: "u2",
name: "Marcus Johnson", name: "Marcus Johnson",
email: "marcus@coastalit.com", email: "marcus@coastalit.com",
phone: "+27 62 555 0102",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128", 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", id: "u3",
name: "Emily Rodriguez", name: "Emily Rodriguez",
email: "emily@coastalit.com", email: "emily@coastalit.com",
phone: "+27 73 555 0103",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128", 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", id: "u4",
name: "David Kim", name: "David Kim",
email: "david@coastalit.com", email: "david@coastalit.com",
phone: "+27 64 555 0104",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128", 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", id: "u5",
name: "Jessica Patel", name: "Jessica Patel",
email: "jessica@coastalit.com", email: "jessica@coastalit.com",
phone: "+27 82 555 0105",
role: "sales", role: "sales",
active: false, active: false,
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128", 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", id: "u6",
name: "Alex Thompson", name: "Alex Thompson",
email: "alex@coastalit.com", email: "alex@coastalit.com",
phone: "+27 72 555 0106",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128", 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", id: "u7",
name: "Rachel Williams", name: "Rachel Williams",
email: "rachel@coastalit.com", email: "rachel@coastalit.com",
phone: "+27 64 555 0107",
role: "sales", role: "sales",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128", 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", id: "u8",
name: "Tom Nakamura", name: "Tom Nakamura",
email: "tom@coastalit.com", email: "tom@coastalit.com",
phone: "+27 83 555 0108",
role: "admin", role: "admin",
active: true, active: true,
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128", avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
+508
View File
@@ -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
View File
@@ -17,6 +17,7 @@ export interface SessionUser {
id: string; id: string;
username: string; username: string;
email: string; email: string;
phone: string | null;
firstName: string; firstName: string;
lastName: string; lastName: string;
role: string; role: string;
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) { export async function getUserById(id: string) {
const result = await query( 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, u.is_active, u.avatar_url,
r.name AS role_name r.name AS role_name
FROM users u FROM users u
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string, id: dbUser.id as string,
username: dbUser.username as string, username: dbUser.username as string,
email: dbUser.email as string, email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string, firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string, lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(), role: roleMapping[roleName] || roleName.toLowerCase(),
+17
View File
@@ -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
}
+1
View File
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [ const publicRoutes = [
"/login", "/login",
"/join",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/_next/static", "/_next/static",
+1
View File
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
id: u.id, id: u.id,
name: `${u.firstName} ${u.lastName}`, name: `${u.firstName} ${u.lastName}`,
email: u.email, email: u.email,
phone: u.phone || "",
role: u.role, role: u.role,
active: true, active: true,
avatar: u.avatar, avatar: u.avatar,
+1
View File
@@ -10,6 +10,7 @@ export interface User {
id: string; id: string;
name: string; name: string;
email: string; email: string;
phone?: string;
role: UserRole; role: UserRole;
active: boolean; active: boolean;
avatar: string; avatar: string;