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 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")
|
||||||
|
|||||||
@@ -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,
|
"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
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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"
|
"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.")
|
||||||
|
|||||||
@@ -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",
|
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",
|
||||||
|
|||||||
@@ -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;
|
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(),
|
||||||
|
|||||||
@@ -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 = [
|
const publicRoutes = [
|
||||||
"/login",
|
"/login",
|
||||||
|
"/join",
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
"/api/auth/logout",
|
"/api/auth/logout",
|
||||||
"/_next/static",
|
"/_next/static",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user