Compare commits
48 Commits
1adc4806fa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ec8207fca7 | |||
| 33a4f9dfa9 | |||
| 82e8ce8ae9 | |||
| 343f814569 | |||
| 4c09635d30 | |||
| 20a1744e7f | |||
| c9df7787d5 | |||
| fa39f2af9d | |||
| 9bbaf70145 | |||
| 5668a63370 | |||
| 3af622f53d | |||
| 1465016b56 | |||
| 906e37e845 | |||
| 52a489759f | |||
| 1f032dc098 | |||
| 1adb6544c2 | |||
| 56ff9995a2 | |||
| 33753a1291 | |||
| 06912f3e59 | |||
| f7258d38f6 | |||
| 4c1d994054 | |||
| b245b352e7 | |||
| cd797c5b3c | |||
| 9d9309833b | |||
| 1e332fb50d | |||
| 9acbbb6a19 | |||
| 8a6ad5c6c6 | |||
| b2a2f7e40f | |||
| 2b4749e5e1 | |||
| e872ab9736 | |||
| 4d9a1dc684 | |||
| 203eac2132 | |||
| 97e8da3bfd | |||
| 3a927b25b6 | |||
| b4be369f25 | |||
| 4fd9f7752a | |||
| e78503b5c1 | |||
| d51c84997a | |||
| c355d0e2e5 | |||
| ff56cea4b8 | |||
| 7bd9c17b5f | |||
| 1c717ce7ba | |||
| cc56fe6286 | |||
| cf554a70cd | |||
| 3061ab111c | |||
| 491ff52b90 | |||
| 9cbae2022b | |||
| d6e4908c18 |
@@ -0,0 +1 @@
|
|||||||
|
rust-ai/target/** linguist-generated=true -diff -merge
|
||||||
+3
-4
@@ -47,6 +47,9 @@ error-log
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# rust
|
||||||
|
rust-ai/target/
|
||||||
|
|
||||||
# python
|
# python
|
||||||
browser-use-service/venv/
|
browser-use-service/venv/
|
||||||
browser-use-service/__pycache__/
|
browser-use-service/__pycache__/
|
||||||
@@ -55,7 +58,3 @@ browser-use-service/.env
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
.git
|
|
||||||
.git_bak
|
|
||||||
node_modules
|
|
||||||
target
|
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import http from "node:http"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
// ── Load .env.local ──────────────────────────────────────────────
|
||||||
|
try {
|
||||||
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
|
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||||
|
for (const line of envContent.split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
const eqIdx = trimmed.indexOf("=")
|
||||||
|
if (eqIdx === -1) continue
|
||||||
|
const k = trimmed.substring(0, eqIdx).trim()
|
||||||
|
let v = trimmed.substring(eqIdx + 1).trim()
|
||||||
|
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
||||||
|
if (!process.env[k]) process.env[k] = v
|
||||||
|
}
|
||||||
|
console.log("Loaded .env.local")
|
||||||
|
} catch {
|
||||||
|
// .env.local may not exist, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Config from env ─────────────────────────────────────────────
|
||||||
|
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||||
|
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||||
|
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||||
|
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||||
|
|
||||||
|
// ── Job loading ─────────────────────────────────────────────────
|
||||||
|
function loadJobs() {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
|
const jobs = content
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(l)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
console.log(`Loaded ${jobs.length} job categories`)
|
||||||
|
return jobs
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ai.md management ────────────────────────────────────────────
|
||||||
|
function readInstructions() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInstructions(content) {
|
||||||
|
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToImprovementLog(entry) {
|
||||||
|
const current = readInstructions()
|
||||||
|
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||||
|
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||||
|
const logSection = "\n## Improvement Log"
|
||||||
|
const logIndex = current.indexOf(logSection)
|
||||||
|
if (logIndex !== -1) {
|
||||||
|
const afterLog = current.substring(logIndex + logSection.length)
|
||||||
|
const nextSectionIndex = afterLog.search(/\n## /)
|
||||||
|
if (nextSectionIndex !== -1) {
|
||||||
|
const insertAt = logIndex + logSection.length + nextSectionIndex
|
||||||
|
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
writeInstructions(current + logEntry + "\n")
|
||||||
|
return current + logEntry + "\n"
|
||||||
|
}
|
||||||
|
const updated = current + `\n${logSection}\n${logEntry}\n`
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat handler ────────────────────────────────────────────────
|
||||||
|
async function scrapeFacebook() {
|
||||||
|
const profilePath = process.env.FX_PROFILE || ""
|
||||||
|
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||||
|
const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log"
|
||||||
|
try {
|
||||||
|
const body = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (c) => data += c)
|
||||||
|
res.on("end", () => resolve(data))
|
||||||
|
res.on("error", reject)
|
||||||
|
})
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
req.on("error", reject)
|
||||||
|
req.end()
|
||||||
|
})
|
||||||
|
const data = JSON.parse(body)
|
||||||
|
return data
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLeads(leads) {
|
||||||
|
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
|
||||||
|
let output = `**${leads.length} leads found:**\n\n`
|
||||||
|
for (let i = 0; i < leads.length; i++) {
|
||||||
|
const l = leads[i]
|
||||||
|
output += `${i + 1}. ${l.title || "No title"}\n`
|
||||||
|
if (l.author) output += ` Author: ${l.author}\n`
|
||||||
|
if (l.date) output += ` Date: ${l.date}\n`
|
||||||
|
if (l.url) output += ` URL: ${l.url}\n`
|
||||||
|
output += "\n"
|
||||||
|
}
|
||||||
|
return output.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
const lowerMsg = userMessage.toLowerCase()
|
||||||
|
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
||||||
|
|
||||||
|
if (triggerWords.some(w => lowerMsg.includes(w))) {
|
||||||
|
const result = await scrapeFacebook()
|
||||||
|
if (result && result.success) {
|
||||||
|
return formatLeads(result.leads)
|
||||||
|
}
|
||||||
|
return "Scraper returned no results or encountered an error. Try again later."
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobs = loadedJobs
|
||||||
|
const instructions = readInstructions()
|
||||||
|
|
||||||
|
const jobList = jobs
|
||||||
|
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
||||||
|
|
||||||
|
Available job categories to target:
|
||||||
|
${jobList}
|
||||||
|
|
||||||
|
Current instructions:
|
||||||
|
${instructions}
|
||||||
|
|
||||||
|
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
||||||
|
|
||||||
|
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userMessage },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
options: { temperature: 0.7, num_predict: 1024 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ollamaRes.ok) {
|
||||||
|
const text = await ollamaRes.text()
|
||||||
|
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await ollamaRes.json()
|
||||||
|
const responseText = data.message?.content || ""
|
||||||
|
|
||||||
|
// Try to persist to DB (best-effort)
|
||||||
|
try {
|
||||||
|
if (pgPool && userId) {
|
||||||
|
await pgPool.query(
|
||||||
|
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// table might not exist, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
|
let pgPool = null
|
||||||
|
async function initPg() {
|
||||||
|
if (!DATABASE_URL) return
|
||||||
|
try {
|
||||||
|
const { default: pg } = await import("pg")
|
||||||
|
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
||||||
|
await pgPool.query("SELECT 1")
|
||||||
|
console.log("Connected to PostgreSQL")
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request router ─────────────────────────────────────────────
|
||||||
|
const loadedJobs = loadJobs()
|
||||||
|
|
||||||
|
function sendJSON(res, status, data) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => (body += chunk))
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid JSON"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseURL(req) {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
// CORS
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname } = parseURL(req)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GET /splash — loading screen
|
||||||
|
if (req.method === "GET" && pathname === "/splash") {
|
||||||
|
const splashPath = path.join(ROOT, "splash.html")
|
||||||
|
if (fs.existsSync(splashPath)) {
|
||||||
|
const html = fs.readFileSync(splashPath, "utf-8")
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html" })
|
||||||
|
res.end(html)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(res, 200, { status: "ok" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /health
|
||||||
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
|
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /status — combined health of all services
|
||||||
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
|
const { default: http } = await import("http")
|
||||||
|
const results = { ai: true }
|
||||||
|
// Check scraper
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.scraper = true
|
||||||
|
} catch { results.scraper = false }
|
||||||
|
// Check frontend
|
||||||
|
try {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
results.frontend = true
|
||||||
|
} catch { results.frontend = false }
|
||||||
|
return sendJSON(res, 200, results)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/jobs
|
||||||
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/instructions
|
||||||
|
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||||
|
const instructions = readInstructions()
|
||||||
|
return sendJSON(res, 200, { success: true, instructions })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/instructions
|
||||||
|
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
if (body.content) {
|
||||||
|
writeInstructions(body.content)
|
||||||
|
} else if (body.entry) {
|
||||||
|
appendToImprovementLog(body.entry)
|
||||||
|
}
|
||||||
|
return sendJSON(res, 200, {
|
||||||
|
success: true,
|
||||||
|
instructions: readInstructions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/chat
|
||||||
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const chunks = []
|
||||||
|
req.on("data", c => chunks.push(c))
|
||||||
|
req.on("end", () => {
|
||||||
|
const rawBody = Buffer.concat(chunks).toString()
|
||||||
|
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
|
||||||
|
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(rawBody)
|
||||||
|
// Continue processing
|
||||||
|
processRequest(req, res, body, startTime)
|
||||||
|
} catch {
|
||||||
|
sendJSON(res, 400, { error: "Invalid JSON" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate handler
|
||||||
|
async function processRequest(req, res, body, startTime) {
|
||||||
|
const { message, user_id, user_role } = body
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return sendJSON(res, 400, { error: "message is required" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoles = ["sales", "admin", "super_admin"]
|
||||||
|
if (user_role && !validRoles.includes(user_role)) {
|
||||||
|
return sendJSON(res, 403, { error: "Forbidden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||||
|
return sendJSON(res, 200, { response })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
sendJSON(res, 404, { error: "Not found" })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Request error:", err)
|
||||||
|
sendJSON(res, 500, { error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────────────────────────────
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||||
|
console.log(` Model: ${MODEL}`)
|
||||||
|
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
initPg()
|
||||||
Binary file not shown.
+851
-188
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
python : INFO: Started server process [20044]
|
|
||||||
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
|
|
||||||
+ FullyQualifiedErrorId : NativeCommandError
|
|
||||||
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
|
|
||||||
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
INFO: Started server process [24268]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
Browser Use service starting on port 3008
|
|
||||||
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
|
|
||||||
Scraping WarriorForum...
|
|
||||||
WarriorForum: 55 results
|
|
||||||
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
|
|
||||||
@@ -126,10 +126,10 @@ separate 1:N child tables.
|
|||||||
|
|
||||||
| Username | Password | Role |
|
| Username | Password | Role |
|
||||||
|----------|----------|------|
|
|----------|----------|------|
|
||||||
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
|
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
|
||||||
| `admin_demo` | `AdminAccess@2026` | ADMIN |
|
| `admin_demo` | `[REDACTED]` | ADMIN |
|
||||||
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
|
| `sales_demo` | `[REDACTED]` | SALES_USER |
|
||||||
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
|
| `dev_demo` | `[REDACTED]` | DEVELOPER |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS facebook_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
label VARCHAR(100) NOT NULL,
|
||||||
|
profile_path TEXT NOT NULL,
|
||||||
|
cookie_file TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_scrape_at TIMESTAMPTZ,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_error_at TIMESTAMPTZ,
|
||||||
|
last_error_message TEXT,
|
||||||
|
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||||
|
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
flagged_at TIMESTAMPTZ,
|
||||||
|
flagged_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
leads_found INT NOT NULL DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
detected_flag VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- CRM Security Architecture Upgrade
|
||||||
|
-- Internal Company CRM — Prioritizes control, recovery, and maintainability
|
||||||
|
-- ============================================================================
|
||||||
|
-- Implements:
|
||||||
|
-- • Dual password storage (bcrypt + pgcrypto AES reversible)
|
||||||
|
-- • SUPER_ADMIN master key recovery system
|
||||||
|
-- • Row Level Security (RLS) on CRM tables
|
||||||
|
-- • Database export logging
|
||||||
|
-- • Backup logging
|
||||||
|
-- • Immutable admin action tracking
|
||||||
|
-- • SQL injection defense (parameterized queries enforced app-side)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 1. DUAL PASSWORD STORAGE
|
||||||
|
-- ============================================================================
|
||||||
|
-- password_hash (bcrypt) — used for normal login authentication
|
||||||
|
-- password_encrypted (AES) — reversible encryption for emergency credential recovery
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 2. SUPER_ADMIN MASTER KEY SYSTEM
|
||||||
|
-- ============================================================================
|
||||||
|
-- Stores the master decryption key used with pgp_sym_encrypt/pgp_sym_decrypt.
|
||||||
|
-- Only accessible by SUPER_ADMIN role via security definer functions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS master_keys (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
key_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
key_value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_master_keys_active ON master_keys(key_name) WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 3. DATABASE EXPORT LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
-- Tracks all database exports and SQL dumps performed by SUPER_ADMIN.
|
||||||
|
-- Immutable — never allow deletion or update.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS database_export_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
exported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
export_type VARCHAR(50) NOT NULL,
|
||||||
|
file_name VARCHAR(500) NOT NULL,
|
||||||
|
file_size_bytes BIGINT,
|
||||||
|
record_count INT,
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
exported_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_export_logs_user ON database_export_logs(exported_by);
|
||||||
|
CREATE INDEX idx_export_logs_time ON database_export_logs(exported_at DESC);
|
||||||
|
|
||||||
|
COMMENT ON TABLE database_export_logs IS 'Immutable audit of all database exports. Never DELETE or UPDATE rows.';
|
||||||
|
COMMENT ON COLUMN database_export_logs.export_type IS 'pg_dump, csv_export, full_backup, selective_export';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 4. BACKUP LOGGING
|
||||||
|
-- ============================================================================
|
||||||
|
-- Records every automated or manual database backup.
|
||||||
|
-- Retention: minimum 30 days of backup history.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS backup_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
backup_type VARCHAR(20) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
file_name VARCHAR(500),
|
||||||
|
file_size_bytes BIGINT,
|
||||||
|
pg_dump_exit_code INT,
|
||||||
|
error_message TEXT,
|
||||||
|
checksum VARCHAR(64),
|
||||||
|
verification_status VARCHAR(20),
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
duration_seconds INT,
|
||||||
|
retention_days INT NOT NULL DEFAULT 30,
|
||||||
|
triggered_by UUID REFERENCES users(id),
|
||||||
|
notes TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_backup_logs_time ON backup_logs(started_at DESC);
|
||||||
|
CREATE INDEX idx_backup_logs_status ON backup_logs(status);
|
||||||
|
CREATE INDEX idx_backup_logs_type ON backup_logs(backup_type);
|
||||||
|
CREATE INDEX idx_backup_logs_completed ON backup_logs(status)
|
||||||
|
WHERE status = 'completed';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 5. AUDIT TRIGGER: Password changes
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_password_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, old_data, new_data, changed_by, ip_address
|
||||||
|
) VALUES (
|
||||||
|
'users',
|
||||||
|
NEW.id,
|
||||||
|
'UPDATE',
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), ''),
|
||||||
|
NULLIF(current_setting('app.current_ip', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_password_change ON users;
|
||||||
|
CREATE TRIGGER trg_audit_password_change
|
||||||
|
AFTER UPDATE OF password_hash ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_password_change();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 6. AUDIT TRIGGER: User creation
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_user_creation()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, new_data, changed_by
|
||||||
|
) VALUES (
|
||||||
|
'users',
|
||||||
|
NEW.id,
|
||||||
|
'CREATE',
|
||||||
|
jsonb_build_object(
|
||||||
|
'username', NEW.username,
|
||||||
|
'email', NEW.email,
|
||||||
|
'first_name', NEW.first_name,
|
||||||
|
'last_name', NEW.last_name,
|
||||||
|
'is_active', NEW.is_active
|
||||||
|
),
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_user_create ON users;
|
||||||
|
CREATE TRIGGER trg_audit_user_create
|
||||||
|
AFTER INSERT ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_user_creation();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 7. AUDIT TRIGGER: Database exports
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION audit_database_export()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, new_data, changed_by
|
||||||
|
) VALUES (
|
||||||
|
'database_export_logs',
|
||||||
|
NEW.id,
|
||||||
|
'CREATE',
|
||||||
|
jsonb_build_object(
|
||||||
|
'export_type', NEW.export_type,
|
||||||
|
'file_name', NEW.file_name,
|
||||||
|
'file_size_bytes', NEW.file_size_bytes,
|
||||||
|
'record_count', NEW.record_count
|
||||||
|
),
|
||||||
|
NEW.exported_by
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_database_export ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_audit_database_export
|
||||||
|
AFTER INSERT ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_database_export();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 8. AUDIT TRIGGER: Login/logout already exists via login_attempts trigger
|
||||||
|
-- (trg_audit_login in 001_schema.sql)
|
||||||
|
-- This enhances it to also track session-level events.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_login ON login_attempts;
|
||||||
|
CREATE TRIGGER trg_audit_login
|
||||||
|
AFTER INSERT ON login_attempts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_login();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 9. ROW LEVEL SECURITY
|
||||||
|
-- ============================================================================
|
||||||
|
-- RLS policies enforce data visibility per role:
|
||||||
|
-- SALES_USER → only sees records assigned to them
|
||||||
|
-- ADMIN → sees operational records for investigation
|
||||||
|
-- SUPER_ADMIN → sees everything
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Helper function to get current user's role hierarchy level
|
||||||
|
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
|
||||||
|
RETURNS INT AS $$
|
||||||
|
DECLARE
|
||||||
|
uid UUID;
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
uid := NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF uid IS NULL THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN (
|
||||||
|
SELECT MIN(r.hierarchy_level)
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = uid
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Helper function to get current user's ID from session setting
|
||||||
|
CREATE OR REPLACE FUNCTION current_user_id()
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
BEGIN
|
||||||
|
BEGIN
|
||||||
|
RETURN NULLIF(current_setting('app.current_user_id', true), '')::UUID;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE;
|
||||||
|
|
||||||
|
-- SALES_USER level = 3, ADMIN level = 2, SUPER_ADMIN level = 1
|
||||||
|
|
||||||
|
-- ── customers ──
|
||||||
|
ALTER TABLE customers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_select ON customers;
|
||||||
|
CREATE POLICY rls_customers_select ON customers
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN see all
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_insert ON customers;
|
||||||
|
CREATE POLICY rls_customers_insert ON customers
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2 -- ADMIN and SUPER_ADMIN can assign any owner
|
||||||
|
OR owner_id = current_user_id() -- SALES_USER can only assign to self
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_update ON customers;
|
||||||
|
CREATE POLICY rls_customers_update ON customers
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR (
|
||||||
|
owner_id = current_user_id()
|
||||||
|
AND owner_id = current_user_id() -- SALES_USER cannot reassign ownership
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_customers_delete ON customers;
|
||||||
|
CREATE POLICY rls_customers_delete ON customers
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── leads ──
|
||||||
|
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_select ON leads;
|
||||||
|
CREATE POLICY rls_leads_select ON leads
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_insert ON leads;
|
||||||
|
CREATE POLICY rls_leads_insert ON leads
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_update ON leads;
|
||||||
|
CREATE POLICY rls_leads_update ON leads
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_leads_delete ON leads;
|
||||||
|
CREATE POLICY rls_leads_delete ON leads
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── opportunities ──
|
||||||
|
ALTER TABLE opportunities ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_select ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_select ON opportunities
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_insert ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_insert ON opportunities
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_update ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_update ON opportunities
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR owner_id = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_opportunities_delete ON opportunities;
|
||||||
|
CREATE POLICY rls_opportunities_delete ON opportunities
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── communications ──
|
||||||
|
ALTER TABLE communications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_select ON communications;
|
||||||
|
CREATE POLICY rls_communications_select ON communications
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR created_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_insert ON communications;
|
||||||
|
CREATE POLICY rls_communications_insert ON communications
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_communications_delete ON communications;
|
||||||
|
CREATE POLICY rls_communications_delete ON communications
|
||||||
|
FOR DELETE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ── tasks ──
|
||||||
|
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_tasks_select ON tasks;
|
||||||
|
CREATE POLICY rls_tasks_select ON tasks
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NULL
|
||||||
|
OR current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
OR assigned_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS rls_tasks_update ON tasks;
|
||||||
|
CREATE POLICY rls_tasks_update ON tasks
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND (
|
||||||
|
current_user_hierarchy_level() <= 2
|
||||||
|
OR assigned_to = current_user_id()
|
||||||
|
OR assigned_by = current_user_id()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 10. IMMUTABLE AUDIT LOG PROTECTION
|
||||||
|
-- ============================================================================
|
||||||
|
-- Prevent deletion or update of audit_logs at the database level.
|
||||||
|
-- Even SUPER_ADMIN cannot delete historical audit records.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_audit_mutation()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
RAISE EXCEPTION 'IMMUTABLE: audit_logs cannot be modified or deleted. All changes are permanently recorded.';
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_audit_delete ON audit_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_audit_delete
|
||||||
|
BEFORE DELETE ON audit_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_audit_update ON audit_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_audit_update
|
||||||
|
BEFORE UPDATE ON audit_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
-- Same protection for database_export_logs
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_export_delete ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_export_delete
|
||||||
|
BEFORE DELETE ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_prevent_export_update ON database_export_logs;
|
||||||
|
CREATE TRIGGER trg_prevent_export_update
|
||||||
|
BEFORE UPDATE ON database_export_logs
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION prevent_audit_mutation();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 11. ENCRYPTION FUNCTIONS
|
||||||
|
-- ============================================================================
|
||||||
|
-- Uses pgcrypto's pgp_sym_encrypt / pgp_sym_decrypt with the master key.
|
||||||
|
-- Master key is stored in master_keys table, fetched by security definer functions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Get the active master decryption key
|
||||||
|
-- Only callable by SUPER_ADMIN
|
||||||
|
CREATE OR REPLACE FUNCTION get_master_decryption_key()
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
key_value TEXT;
|
||||||
|
uid UUID;
|
||||||
|
user_level INT;
|
||||||
|
BEGIN
|
||||||
|
uid := current_user_id();
|
||||||
|
IF uid IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Not authenticated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
user_level := current_user_hierarchy_level();
|
||||||
|
IF user_level IS NULL OR user_level > 1 THEN
|
||||||
|
RAISE EXCEPTION 'ACCESS DENIED: Only SUPER_ADMIN can access the master decryption key';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT mk.key_value INTO key_value
|
||||||
|
FROM master_keys mk
|
||||||
|
WHERE mk.is_active = TRUE
|
||||||
|
ORDER BY mk.created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
RETURN key_value;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Encrypt a plaintext password using the active master key
|
||||||
|
CREATE OR REPLACE FUNCTION encrypt_password(p_plaintext TEXT)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
master_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
SELECT key_value INTO master_key
|
||||||
|
FROM master_keys
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF master_key IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'No active master key found. Contact SUPER_ADMIN.';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN encode(
|
||||||
|
pgp_sym_encrypt(p_plaintext, master_key, 'compress-algo=2, cipher-algo=aes256'),
|
||||||
|
'escape'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- Decrypt a password that was encrypted with encrypt_password()
|
||||||
|
CREATE OR REPLACE FUNCTION decrypt_password(p_encrypted TEXT)
|
||||||
|
RETURNS TEXT AS $$
|
||||||
|
DECLARE
|
||||||
|
master_key TEXT;
|
||||||
|
BEGIN
|
||||||
|
master_key := get_master_decryption_key();
|
||||||
|
RETURN pgp_sym_decrypt(decode(p_encrypted, 'escape'), master_key);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 12. RLS BYPASS FOR SUPER_ADMIN
|
||||||
|
-- ============================================================================
|
||||||
|
-- SUPER_ADMIN bypasses all RLS. This function is used by triggers/policies
|
||||||
|
-- to check if the current user should bypass restrictions.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION is_super_admin()
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN COALESCE(current_user_hierarchy_level(), 999) <= 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 13. SEED DATA: Master key (for development/testing)
|
||||||
|
-- ============================================================================
|
||||||
|
-- In production, this key should be set via a secure deployment process.
|
||||||
|
-- The key is stored in the database and encrypted at rest by PostgreSQL.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO master_keys (key_name, key_value, description, created_by)
|
||||||
|
SELECT
|
||||||
|
'MASTER_DECRYPTION_KEY',
|
||||||
|
encode(gen_random_bytes(32), 'hex'),
|
||||||
|
'Master key for reversible password encryption. Used with pgp_sym_encrypt/decrypt.',
|
||||||
|
'00000000-0000-0000-0000-000000000001'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM master_keys WHERE key_name = 'MASTER_DECRYPTION_KEY'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- 14. UPDATE SEED DATA: Encrypt passwords for existing test accounts
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('SuperAdmin@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000001' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('AdminAccess@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000002' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('SalesAccess@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
||||||
|
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUTURE REQUIREMENT NOTE:
|
||||||
|
-- If this system is ever exposed publicly, remove reversible password storage
|
||||||
|
-- immediately. Keep bcrypt only. Delete the password_encrypted column and
|
||||||
|
-- master_keys table. The MASTER_DECRYPTION_KEY must never be exposed externally.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Bug Reporting System
|
||||||
|
-- ============================================================================
|
||||||
|
-- Access rules:
|
||||||
|
-- ALL authenticated users can INSERT (submit bug reports)
|
||||||
|
-- Only ADMIN and SUPER_ADMIN can SELECT, UPDATE (view/manage)
|
||||||
|
-- SALES_USER and DEVELOPER cannot view bug_reports after submission
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bug_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
reported_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'medium'
|
||||||
|
CHECK (severity IN ('low', 'medium', 'high', 'critical')),
|
||||||
|
page_url TEXT,
|
||||||
|
screenshot_url TEXT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'open'
|
||||||
|
CHECK (status IN ('open', 'in_progress', 'resolved', 'closed')),
|
||||||
|
assigned_to UUID REFERENCES users(id),
|
||||||
|
resolution_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_reported_by ON bug_reports(reported_by);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_status ON bug_reports(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_severity ON bug_reports(severity);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_assigned ON bug_reports(assigned_to);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bug_reports_created ON bug_reports(created_at DESC);
|
||||||
|
|
||||||
|
-- RLS: Allow INSERT for all authenticated users
|
||||||
|
-- Allow SELECT/UPDATE only for ADMIN and SUPER_ADMIN
|
||||||
|
ALTER TABLE bug_reports ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_insert ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_insert ON bug_reports
|
||||||
|
FOR INSERT
|
||||||
|
WITH CHECK (
|
||||||
|
current_user_id() IS NOT NULL
|
||||||
|
AND reported_by = current_user_id()
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_select ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_select ON bug_reports
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS bug_reports_update ON bug_reports;
|
||||||
|
CREATE POLICY bug_reports_update ON bug_reports
|
||||||
|
FOR UPDATE
|
||||||
|
USING (
|
||||||
|
current_user_hierarchy_level() IS NOT NULL
|
||||||
|
AND current_user_hierarchy_level() <= 2
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Audit trigger for bug report actions
|
||||||
|
CREATE OR REPLACE FUNCTION audit_bug_report_action()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
'BUG_CREATED',
|
||||||
|
jsonb_build_object(
|
||||||
|
'title', NEW.title,
|
||||||
|
'severity', NEW.severity,
|
||||||
|
'page_url', NEW.page_url,
|
||||||
|
'status', NEW.status
|
||||||
|
),
|
||||||
|
NEW.reported_by
|
||||||
|
);
|
||||||
|
ELSIF TG_OP = 'UPDATE' THEN
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
CASE NEW.status
|
||||||
|
WHEN 'resolved' THEN 'BUG_RESOLVED'
|
||||||
|
ELSE 'BUG_UPDATED'
|
||||||
|
END,
|
||||||
|
jsonb_build_object(
|
||||||
|
'old_status', OLD.status,
|
||||||
|
'new_status', NEW.status,
|
||||||
|
'assigned_to', NEW.assigned_to,
|
||||||
|
'resolution_notes', NEW.resolution_notes
|
||||||
|
),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
IF OLD.assigned_to IS DISTINCT FROM NEW.assigned_to AND NEW.assigned_to IS NOT NULL THEN
|
||||||
|
INSERT INTO audit_logs (table_name, record_id, action, new_data, changed_by)
|
||||||
|
VALUES (
|
||||||
|
'bug_reports',
|
||||||
|
NEW.id,
|
||||||
|
'BUG_ASSIGNED',
|
||||||
|
jsonb_build_object(
|
||||||
|
'assigned_to', NEW.assigned_to,
|
||||||
|
'previous_assignee', OLD.assigned_to,
|
||||||
|
'status', NEW.status
|
||||||
|
),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS trg_audit_bug_report ON bug_reports;
|
||||||
|
CREATE TRIGGER trg_audit_bug_report
|
||||||
|
AFTER INSERT OR UPDATE ON bug_reports
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION audit_bug_report_action();
|
||||||
|
|
||||||
|
-- Widen audit action constraint to include bug report events
|
||||||
|
ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS chk_audit_action;
|
||||||
|
ALTER TABLE audit_logs ADD CONSTRAINT chk_audit_action
|
||||||
|
CHECK (action::text = ANY (ARRAY[
|
||||||
|
'CREATE', 'UPDATE', 'DELETE',
|
||||||
|
'BUG_CREATED', 'BUG_UPDATED', 'BUG_ASSIGNED', 'BUG_RESOLVED',
|
||||||
|
'LOGIN', 'LOGOUT'
|
||||||
|
]::text[]));
|
||||||
|
|
||||||
|
-- Prevent SALES_USER and DEVELOPER from reading bug_reports directly
|
||||||
|
-- via RLS bypass attempts (additional safety via trigger)
|
||||||
|
CREATE OR REPLACE FUNCTION prevent_bug_report_read_bypass()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
user_level INT;
|
||||||
|
BEGIN
|
||||||
|
user_level := current_user_hierarchy_level();
|
||||||
|
IF user_level IS NULL OR user_level > 2 THEN
|
||||||
|
RAISE EXCEPTION 'ACCESS DENIED: Only ADMIN and SUPER_ADMIN can view bug reports.';
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
@@ -34,5 +34,11 @@ BEGIN;
|
|||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||||
\i 009_settings.sql
|
\i 009_settings.sql
|
||||||
|
|
||||||
|
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
|
||||||
|
\i 013_security_upgrade.sql
|
||||||
|
|
||||||
|
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
||||||
|
\i 014_bug_reports.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+25
File diff suppressed because one or more lines are too long
@@ -1,4 +1,12 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
|
import crypto from "crypto"
|
||||||
|
|
||||||
|
// In development, generate a random JWT secret on every server start.
|
||||||
|
// This invalidates all previously issued JWT tokens, ensuring the user
|
||||||
|
// must re-authenticate after each dev server restart.
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
process.env.JWT_SECRET = crypto.randomBytes(32).toString("hex")
|
||||||
|
}
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
@@ -12,6 +20,7 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"mcp": {
|
||||||
|
"playwright": {
|
||||||
|
"type": "local",
|
||||||
|
"command": [
|
||||||
|
"playwright-mcp.cmd",
|
||||||
|
"--browser",
|
||||||
|
"chrome",
|
||||||
|
"--user-data-dir",
|
||||||
|
"C:\\Users\\Caitlin\\AppData\\Local\\Google\\Chrome\\User Data\\Default",
|
||||||
|
],
|
||||||
|
"enabled": true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
Generated
+438
-10
@@ -25,12 +25,13 @@
|
|||||||
"@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",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"emoji-mart": "^5.6.0",
|
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
@@ -41,6 +42,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",
|
||||||
@@ -443,6 +446,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -459,6 +465,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -475,6 +484,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -491,6 +503,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -507,6 +522,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -523,6 +541,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -539,6 +560,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -561,6 +585,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -583,6 +610,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -605,6 +635,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -627,6 +660,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -649,6 +685,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -709,6 +748,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -776,13 +816,15 @@
|
|||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.0.4",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
|
||||||
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw=="
|
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "15.0.4",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
|
||||||
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
|
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "3.3.1"
|
"fast-glob": "3.3.1"
|
||||||
}
|
}
|
||||||
@@ -794,6 +836,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -809,6 +852,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -824,6 +868,10 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -839,6 +887,10 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -854,6 +906,10 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -869,6 +925,10 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -884,6 +944,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -899,6 +960,7 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -1879,15 +1941,119 @@
|
|||||||
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||||
|
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "^0.4.2",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.108.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.108.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||||
|
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.108.2",
|
||||||
|
"@supabase/functions-js": "2.108.2",
|
||||||
|
"@supabase/postgrest-js": "2.108.2",
|
||||||
|
"@supabase/realtime-js": "2.108.2",
|
||||||
|
"@supabase/storage-js": "2.108.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/counter": {
|
"node_modules/@swc/counter": {
|
||||||
"version": "0.1.3",
|
"version": "0.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
|
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.13",
|
"version": "0.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
|
||||||
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
|
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
@@ -1933,6 +2099,15 @@
|
|||||||
"tslib": "^2.4.0"
|
"tslib": "^2.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cors": {
|
||||||
|
"version": "2.8.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-array": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -2009,7 +2184,6 @@
|
|||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -2050,6 +2224,15 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.61.0",
|
"version": "8.61.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||||
@@ -2608,6 +2791,19 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/accepts": {
|
||||||
|
"version": "1.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
|
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-types": "~2.1.34",
|
||||||
|
"negotiator": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@@ -2965,6 +3161,15 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/base64id": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^4.5.0 || >= 5.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/baseline-browser-mapping": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.37",
|
"version": "2.10.37",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||||
@@ -3240,6 +3445,7 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1",
|
"color-convert": "^2.0.1",
|
||||||
@@ -3271,6 +3477,7 @@
|
|||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "^1.0.0",
|
"color-name": "^1.0.0",
|
||||||
@@ -3342,6 +3549,36 @@
|
|||||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cors": {
|
||||||
|
"version": "2.8.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||||
|
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-assign": "^4",
|
||||||
|
"vary": "^1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -3544,7 +3781,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
},
|
},
|
||||||
@@ -3606,6 +3842,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3672,7 +3909,8 @@
|
|||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-mart/-/emoji-mart-5.6.0.tgz",
|
||||||
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
"integrity": "sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/emoji-regex": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "9.2.2",
|
"version": "9.2.2",
|
||||||
@@ -3680,6 +3918,58 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io": {
|
||||||
|
"version": "6.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||||
|
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cors": "^2.8.12",
|
||||||
|
"@types/node": ">=10.0.0",
|
||||||
|
"@types/ws": "^8.5.12",
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "2.0.0",
|
||||||
|
"cookie": "~0.7.2",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.21.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||||
|
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.21.0",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io/node_modules/cookie": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.2",
|
"version": "1.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||||
@@ -3933,6 +4223,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
|
||||||
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
|
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.0.4",
|
"@next/eslint-plugin-next": "15.0.4",
|
||||||
"@rushstack/eslint-patch": "^1.10.3",
|
"@rushstack/eslint-patch": "^1.10.3",
|
||||||
@@ -4279,6 +4570,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
@@ -4295,6 +4587,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -4735,6 +5028,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4812,6 +5114,7 @@
|
|||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/is-async-function": {
|
"node_modules/is-async-function": {
|
||||||
@@ -5453,6 +5756,27 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-db": {
|
||||||
|
"version": "1.52.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mime-types": {
|
||||||
|
"version": "2.1.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mime-db": "1.52.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -5490,8 +5814,7 @@
|
|||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
@@ -5542,11 +5865,21 @@
|
|||||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/negotiator": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.0.4",
|
"version": "15.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
|
||||||
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
|
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.0.4",
|
"@next/env": "15.0.4",
|
||||||
"@swc/counter": "0.1.3",
|
"@swc/counter": "0.1.3",
|
||||||
@@ -6726,6 +7059,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color": "^4.2.3",
|
"color": "^4.2.3",
|
||||||
@@ -6870,11 +7204,68 @@
|
|||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-arrayish": "^0.3.1"
|
"is-arrayish": "^0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"accepts": "~1.3.4",
|
||||||
|
"base64id": "~2.0.0",
|
||||||
|
"cors": "~2.8.5",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io": "~6.6.0",
|
||||||
|
"socket.io-adapter": "~2.5.2",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-adapter": {
|
||||||
|
"version": "2.5.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||||
|
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"ws": "~8.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||||
|
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
@@ -7508,8 +7899,7 @@
|
|||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/unrs-resolver": {
|
"node_modules/unrs-resolver": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
@@ -7633,6 +8023,15 @@
|
|||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/vary": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vaul": {
|
"node_modules/vaul": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
@@ -7806,6 +8205,35 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+11
-5
@@ -4,12 +4,14 @@
|
|||||||
"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:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"",
|
||||||
|
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"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 \"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": "cd rust-ai && cargo run",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "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",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
@@ -32,6 +34,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 +51,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",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
|
||||||
+196
-32
@@ -15,6 +15,7 @@ use tokio::sync::Mutex;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use chrono::Timelike;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -93,6 +94,25 @@ struct LeadStore {
|
|||||||
max_size: usize,
|
max_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScrapeResponse {
|
||||||
|
success: bool,
|
||||||
|
leads: Vec<ScrapeLead>,
|
||||||
|
flagged: bool,
|
||||||
|
flag_reason: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
struct ScrapeLead {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
author: String,
|
||||||
|
date: String,
|
||||||
|
content: String,
|
||||||
|
source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl LeadStore {
|
impl LeadStore {
|
||||||
fn new(max_size: usize) -> Self {
|
fn new(max_size: usize) -> Self {
|
||||||
Self { leads: Vec::new(), max_size }
|
Self { leads: Vec::new(), max_size }
|
||||||
@@ -188,7 +208,7 @@ fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (Stat
|
|||||||
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||||
})?;
|
})?;
|
||||||
match claims.role.as_str() {
|
match claims.role.to_lowercase().as_str() {
|
||||||
"sales" | "admin" | "super_admin" => Ok(claims),
|
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||||
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||||
}
|
}
|
||||||
@@ -266,30 +286,64 @@ async fn handle_chat(
|
|||||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||||
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
let service_url = "http://localhost:3008/scrape/all".to_string();
|
let base_url = "http://localhost:3008/scrape/facebook";
|
||||||
if let Ok(resp) = state.http_client
|
use std::fmt::Write;
|
||||||
.post(&service_url)
|
let mut service_url = base_url.to_string();
|
||||||
.timeout(Duration::from_secs(120))
|
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||||
.send()
|
"SELECT id, profile_path FROM facebook_accounts \
|
||||||
|
WHERE is_active = TRUE AND flagged = FALSE \
|
||||||
|
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
|
let encoded: String = path.chars().map(|c| match c {
|
||||||
info!("Scraped {} leads from {}", leads_data.len(), service_url);
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
|
||||||
|
_ => format!("%{:02X}", c as u8),
|
||||||
|
}).collect();
|
||||||
|
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
|
||||||
|
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
|
||||||
|
} else {
|
||||||
|
warn!("No active Facebook account found for on-demand scrape");
|
||||||
|
}
|
||||||
|
let req_builder = state.http_client.post(&service_url);
|
||||||
|
|
||||||
|
match req_builder.send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
info!("Python scrape response ({}): {} bytes", status, body.len());
|
||||||
|
if body.starts_with('{') {
|
||||||
|
match serde_json::from_str::<ScrapeResponse>(&body) {
|
||||||
|
Ok(scrape_resp) => {
|
||||||
|
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
|
||||||
|
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
|
||||||
|
warn!("Python returned error: {:?}", scrape_resp.error);
|
||||||
|
}
|
||||||
let mut store = state.leads.lock().await;
|
let mut store = state.leads.lock().await;
|
||||||
for item in &leads_data {
|
for item in &scrape_resp.leads {
|
||||||
store.push(Lead {
|
store.push(Lead {
|
||||||
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
title: truncate(&item.title, 120),
|
||||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
url: item.url.clone(),
|
||||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||||
found_at: now,
|
found_at: now,
|
||||||
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
author: truncate(&item.author, 60),
|
||||||
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
date: truncate(&item.date, 30),
|
||||||
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
content: truncate(&item.content, 300),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
error!("Scraper service unreachable: {}", service_url);
|
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Scraper request error: {} - URL: {}", e, base_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||||
@@ -392,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");
|
||||||
|
|
||||||
@@ -411,7 +465,7 @@ async fn main() {
|
|||||||
info!("Connected to PostgreSQL");
|
info!("Connected to PostgreSQL");
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(120))
|
.timeout(Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to build HTTP client");
|
.expect("Failed to build HTTP client");
|
||||||
|
|
||||||
@@ -441,7 +495,7 @@ async fn main() {
|
|||||||
.route("/ai/jobs", get(handle_jobs))
|
.route("/ai/jobs", get(handle_jobs))
|
||||||
.route("/health", get(handle_health))
|
.route("/health", get(handle_health))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state.clone());
|
||||||
|
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
info!("CRM AI server listening on {}", addr);
|
info!("CRM AI server listening on {}", addr);
|
||||||
@@ -451,10 +505,11 @@ async fn main() {
|
|||||||
.expect("Failed to bind address");
|
.expect("Failed to bind address");
|
||||||
|
|
||||||
let bg_leads = lead_store.clone();
|
let bg_leads = lead_store.clone();
|
||||||
let bg_url = "http://localhost:3008/scrape/all".to_string();
|
let bg_db = state.db.clone();
|
||||||
|
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(120))
|
.timeout(Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -463,25 +518,125 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Initial delay to let user on-demand requests take priority
|
||||||
|
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// 10% random cycle skip — makes pattern non-periodic
|
||||||
|
if rand::thread_rng().gen_range(0..100) < 10 {
|
||||||
|
info!("Skipping this scrape cycle (random 10% skip)");
|
||||||
|
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip night hours (23:00 – 06:00)
|
||||||
|
let hour = chrono::Local::now().hour();
|
||||||
|
if hour < 6 || hour >= 23 {
|
||||||
|
info!("Night hours ({}) — skipping scrape", hour);
|
||||||
|
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
match client.post(&bg_url).send().await {
|
|
||||||
|
// Pick next active un-flagged account
|
||||||
|
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||||
|
"SELECT id, profile_path FROM facebook_accounts \
|
||||||
|
WHERE is_active = TRUE AND flagged = FALSE \
|
||||||
|
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||||
|
)
|
||||||
|
.fetch_optional(&bg_db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match account {
|
||||||
|
Ok(Some((account_id, profile_path))) => {
|
||||||
|
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
if resp.status().is_success() {
|
if resp.status().is_success() {
|
||||||
match resp.json::<Vec<serde_json::Value>>().await {
|
match resp.json::<ScrapeResponse>().await {
|
||||||
Ok(data) => {
|
Ok(data) => {
|
||||||
|
let leads_count = data.leads.len() as i32;
|
||||||
|
if data.flagged {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
|
||||||
|
flagged_reason = $2, last_error_at = NOW(), \
|
||||||
|
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
|
||||||
|
WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&data.flag_reason)
|
||||||
|
.bind(&data.error)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
|
||||||
|
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO notifications (user_id, type, title, description, link) \
|
||||||
|
SELECT id, 'warning', 'Facebook Account Flagged', \
|
||||||
|
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
|
||||||
|
NULL \
|
||||||
|
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
|
||||||
|
JOIN roles r ON r.id = ur.role_id \
|
||||||
|
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
|
||||||
|
)
|
||||||
|
.bind(&account_id.to_string())
|
||||||
|
.bind(reason)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
} else if data.success {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
|
||||||
|
last_success_at = NOW(), consecutive_failures = 0, \
|
||||||
|
updated_at = NOW() WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
|
||||||
let mut store = bg_leads.lock().await;
|
let mut store = bg_leads.lock().await;
|
||||||
for item in &data {
|
for item in &data.leads {
|
||||||
store.push(Lead {
|
store.push(Lead {
|
||||||
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
title: truncate(&item.title, 120),
|
||||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
url: item.url.clone(),
|
||||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||||
found_at: now,
|
found_at: now,
|
||||||
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
author: truncate(&item.author, 60),
|
||||||
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
date: truncate(&item.date, 30),
|
||||||
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
content: truncate(&item.content, 300),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
|
||||||
|
} else {
|
||||||
|
// Increment failures; auto-flag if >= 3 consecutive
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET last_error_at = NOW(), \
|
||||||
|
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
|
||||||
|
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
|
||||||
|
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
|
||||||
|
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
|
||||||
|
updated_at = NOW() WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&data.error)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO facebook_scrape_logs \
|
||||||
|
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
|
||||||
|
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(data.success && !data.flagged)
|
||||||
|
.bind(leads_count)
|
||||||
|
.bind(&data.error)
|
||||||
|
.bind(&data.flag_reason)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Failed to parse scraper JSON: {}", e);
|
warn!("Failed to parse scraper JSON: {}", e);
|
||||||
@@ -495,8 +650,17 @@ async fn main() {
|
|||||||
warn!("Scraper request failed: {}", e);
|
warn!("Scraper request failed: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let delay = rand::thread_rng().gen_range(120..300);
|
}
|
||||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
Ok(None) => {
|
||||||
|
info!("No active Facebook accounts available — skipping scrape cycle");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to query Facebook accounts: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h – 5.5h
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
param(
|
||||||
|
[string]$BackupDir = "$PSScriptRoot\..\backups",
|
||||||
|
[int]$RetentionDays = 30
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$startTime = Get-Date
|
||||||
|
|
||||||
|
# Ensure backup directory exists
|
||||||
|
if (-not (Test-Path $BackupDir)) {
|
||||||
|
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine database URL from env or .env.local
|
||||||
|
$dbUrl = $env:DATABASE_URL
|
||||||
|
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
|
||||||
|
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
|
||||||
|
if ($envContent -match 'DATABASE_URL=(.+)') {
|
||||||
|
$dbUrl = $Matches[1].Trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $dbUrl) {
|
||||||
|
Write-Error "DATABASE_URL not found. Set it as an environment variable or in .env.local"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse the database URL
|
||||||
|
$uri = [System.Uri]$dbUrl
|
||||||
|
$pgUser = $uri.UserInfo.Split(':')[0]
|
||||||
|
$pgPass = $uri.UserInfo.Split(':')[1]
|
||||||
|
$pgHost = $uri.Host
|
||||||
|
$pgPort = $uri.Port
|
||||||
|
$pgDb = $uri.AbsolutePath.TrimStart('/')
|
||||||
|
|
||||||
|
# Set PGPASSWORD for pg_dump
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||||
|
$filename = "crm_backup_$timestamp.sql"
|
||||||
|
$filepath = Join-Path $BackupDir $filename
|
||||||
|
|
||||||
|
Write-Output "Starting backup of database '$pgDb' to $filepath"
|
||||||
|
Write-Output "Database: $pgHost:$pgPort/$pgDb"
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Run pg_dump
|
||||||
|
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
|
||||||
|
"pg_dump"
|
||||||
|
} else {
|
||||||
|
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
|
||||||
|
$exitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
if ($exitCode -ne 0 -or -not (Test-Path $filename)) {
|
||||||
|
throw "pg_dump failed with exit code $exitCode"
|
||||||
|
}
|
||||||
|
|
||||||
|
Move-Item -Path $filename -Destination $filepath -Force
|
||||||
|
$fileInfo = Get-Item $filepath
|
||||||
|
$fileSize = $fileInfo.Length
|
||||||
|
|
||||||
|
# Verify backup by checking if file is valid custom format
|
||||||
|
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
|
||||||
|
$verifyExitCode = $LASTEXITCODE
|
||||||
|
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
|
||||||
|
|
||||||
|
$verificationStatus = if ($verifyExitCode -eq 0) { "verified" } else { "failed" }
|
||||||
|
|
||||||
|
# Calculate duration
|
||||||
|
$endTime = Get-Date
|
||||||
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
|
|
||||||
|
# Log the backup
|
||||||
|
$logQuery = @"
|
||||||
|
INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
|
||||||
|
VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
|
||||||
|
"@
|
||||||
|
|
||||||
|
# Try to log to database (may fail if db is not reachable for logging, that's OK)
|
||||||
|
try {
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $logQuery 2>&1 | Out-Null
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Could not log backup to database: $_"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output "Backup completed successfully:"
|
||||||
|
Write-Output " File: $filename"
|
||||||
|
Write-Output " Size: $([math]::Round($fileSize / 1MB, 2)) MB"
|
||||||
|
Write-Output " Duration: ${durationSeconds}s"
|
||||||
|
Write-Output " Status: $verificationStatus"
|
||||||
|
|
||||||
|
# Cleanup old backups (beyond retention period)
|
||||||
|
$cutoff = (Get-Date).AddDays(-$RetentionDays)
|
||||||
|
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
|
||||||
|
$_.CreationTime -lt $cutoff
|
||||||
|
}
|
||||||
|
foreach ($old in $oldBackups) {
|
||||||
|
Remove-Item $old.FullName -Force
|
||||||
|
Write-Output "Removed old backup: $($old.Name)"
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
Write-Error "Backup failed: $_"
|
||||||
|
|
||||||
|
# Log failure
|
||||||
|
$endTime = Get-Date
|
||||||
|
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
|
||||||
|
$errorMsg = $_.ToString().Replace("'", "''")
|
||||||
|
$failQuery = @"
|
||||||
|
INSERT INTO backup_logs (backup_type, status, file_name, error_message, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
|
||||||
|
VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
|
||||||
|
"@
|
||||||
|
try {
|
||||||
|
$env:PGPASSWORD = $pgPass
|
||||||
|
& $env:TEMP\pg\pgsql\bin\psql.exe -h $pgHost -p $pgPort -U $pgUser -d $pgDb -c $failQuery 2>&1 | Out-Null
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
} finally {
|
||||||
|
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
})
|
||||||
+453
@@ -0,0 +1,453 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Loading CoastIT CRM</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
background: #0a0a1a;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Robot */
|
||||||
|
.robot {
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 160px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
animation: float 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-12px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-head {
|
||||||
|
width: 70px;
|
||||||
|
height: 60px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border-radius: 16px 16px 8px 8px;
|
||||||
|
margin: 0 auto 4px;
|
||||||
|
position: relative;
|
||||||
|
animation: bob 0.6s ease-in-out infinite alternate;
|
||||||
|
box-shadow: 0 0 30px rgba(99,102,241,0.3);
|
||||||
|
}
|
||||||
|
@keyframes bob {
|
||||||
|
0% { transform: rotate(-3deg); }
|
||||||
|
100% { transform: rotate(3deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-eye {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: 20px;
|
||||||
|
box-shadow: 0 0 8px #22d3ee;
|
||||||
|
animation: blink 3s infinite;
|
||||||
|
}
|
||||||
|
.robot-eye.left { left: 16px; }
|
||||||
|
.robot-eye.right { right: 16px; }
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 94%, 100% { transform: scaleY(1); }
|
||||||
|
97% { transform: scaleY(0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-antenna {
|
||||||
|
width: 4px;
|
||||||
|
height: 12px;
|
||||||
|
background: #8b5cf6;
|
||||||
|
margin: -10px auto 0;
|
||||||
|
border-radius: 2px;
|
||||||
|
animation: antenna-pulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.robot-antenna::after {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
background: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin: -2px auto 0;
|
||||||
|
box-shadow: 0 0 10px #22d3ee;
|
||||||
|
}
|
||||||
|
@keyframes antenna-pulse {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-body {
|
||||||
|
width: 60px;
|
||||||
|
height: 50px;
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 0 20px rgba(99,102,241,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-arm {
|
||||||
|
position: absolute;
|
||||||
|
width: 12px;
|
||||||
|
height: 36px;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 6px;
|
||||||
|
top: 6px;
|
||||||
|
animation: swing 0.8s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.robot-arm.left { left: -16px; transform-origin: top center; }
|
||||||
|
.robot-arm.right { right: -16px; transform-origin: top center; animation-delay: 0.4s; }
|
||||||
|
@keyframes swing {
|
||||||
|
0% { transform: rotate(-25deg); }
|
||||||
|
100% { transform: rotate(25deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-leg {
|
||||||
|
position: absolute;
|
||||||
|
width: 14px;
|
||||||
|
height: 30px;
|
||||||
|
background: #4f46e5;
|
||||||
|
border-radius: 4px;
|
||||||
|
bottom: -28px;
|
||||||
|
animation: step 0.6s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
.robot-leg.left { left: 8px; }
|
||||||
|
.robot-leg.right { right: 8px; animation-delay: 0.3s; }
|
||||||
|
@keyframes step {
|
||||||
|
0% { transform: translateY(0) rotate(-8deg); }
|
||||||
|
100% { transform: translateY(-4px) rotate(8deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status indicators */
|
||||||
|
.services {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.service {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.06);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.service.ready {
|
||||||
|
border-color: rgba(34,211,238,0.3);
|
||||||
|
background: rgba(34,211,238,0.06);
|
||||||
|
}
|
||||||
|
.service.failed {
|
||||||
|
border-color: rgba(239,68,68,0.3);
|
||||||
|
background: rgba(239,68,68,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-name {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.service.ready .service-name {
|
||||||
|
opacity: 1;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
.service.failed .service-name {
|
||||||
|
opacity: 1;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dots {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
transition: all 0.4s;
|
||||||
|
}
|
||||||
|
.service.ready .status-dot {
|
||||||
|
background: #22d3ee;
|
||||||
|
box-shadow: 0 0 6px rgba(34,211,238,0.5);
|
||||||
|
}
|
||||||
|
.service.failed .status-dot {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 0 6px rgba(239,68,68,0.5);
|
||||||
|
}
|
||||||
|
.status-dot:nth-child(2) { transition-delay: 0.1s; }
|
||||||
|
.status-dot:nth-child(3) { transition-delay: 0.2s; }
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.4;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
.service.ready .status-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #22d3ee;
|
||||||
|
}
|
||||||
|
.service.failed .status-text {
|
||||||
|
opacity: 0.8;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading text */
|
||||||
|
.loading-text {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #22d3ee);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
animation: pulse-text 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse-text {
|
||||||
|
0%, 100% { opacity: 0.6; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Launch gear (inline, below everything) */
|
||||||
|
.launch-overlay {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.launch-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.launch-gear {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 4px solid #6366f1;
|
||||||
|
border-top-color: #22d3ee;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.launch-text {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #22d3ee;
|
||||||
|
animation: scale-in 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes scale-in {
|
||||||
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
|
100% { transform: scale(1); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error state */
|
||||||
|
.loading-text.error {
|
||||||
|
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
display: none;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ef4444;
|
||||||
|
margin-top: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.error-msg.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
display: none;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.retry-btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.retry-btn.active {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Robot -->
|
||||||
|
<div class="robot">
|
||||||
|
<div class="robot-antenna"></div>
|
||||||
|
<div class="robot-head">
|
||||||
|
<div class="robot-eye left"></div>
|
||||||
|
<div class="robot-eye right"></div>
|
||||||
|
</div>
|
||||||
|
<div class="robot-body">
|
||||||
|
<div class="robot-arm left"></div>
|
||||||
|
<div class="robot-arm right"></div>
|
||||||
|
<div class="robot-leg left"></div>
|
||||||
|
<div class="robot-leg right"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="services">
|
||||||
|
<div class="service" id="svc-ai">
|
||||||
|
<span class="service-name">AI Server</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-ai">waiting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="service" id="svc-scraper">
|
||||||
|
<span class="service-name">Scraper</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-scraper">waiting...</span>
|
||||||
|
</div>
|
||||||
|
<div class="service" id="svc-frontend">
|
||||||
|
<span class="service-name">Frontend</span>
|
||||||
|
<div class="status-dots">
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
<div class="status-dot"></div>
|
||||||
|
</div>
|
||||||
|
<span class="status-text" id="status-frontend">waiting...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-text" id="loading-text">Loading...</div>
|
||||||
|
|
||||||
|
<!-- Launch gear (inline, below everything) -->
|
||||||
|
<div class="launch-overlay" id="launch-overlay">
|
||||||
|
<div class="launch-gear"></div>
|
||||||
|
<div class="launch-text">🚀 Launching gear!</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error-msg" id="error-msg"></div>
|
||||||
|
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const MAX_ATTEMPTS = 30;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const CHECKS = [
|
||||||
|
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
||||||
|
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
||||||
|
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
||||||
|
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
|
||||||
|
|
||||||
|
async function checkAllServices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/status', { cache: 'no-store' });
|
||||||
|
const data = await res.json();
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
svc.ready = data[svc.key] === true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const svc of CHECKS) svc.ready = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUI() {
|
||||||
|
let allReady = true;
|
||||||
|
let anyFailed = false;
|
||||||
|
const failedNames = [];
|
||||||
|
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
const el = document.getElementById('svc-' + svc.id);
|
||||||
|
const statusText = document.getElementById('status-' + svc.id);
|
||||||
|
el.classList.remove('ready', 'failed');
|
||||||
|
|
||||||
|
if (svc.ready) {
|
||||||
|
el.classList.add('ready');
|
||||||
|
statusText.textContent = MSGS[svc.id];
|
||||||
|
} else if (svc.failed) {
|
||||||
|
el.classList.add('failed');
|
||||||
|
statusText.textContent = 'Failed';
|
||||||
|
anyFailed = true;
|
||||||
|
allReady = false;
|
||||||
|
failedNames.push(NAMES[svc.id]);
|
||||||
|
} else {
|
||||||
|
statusText.textContent = 'waiting...';
|
||||||
|
allReady = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingText = document.getElementById('loading-text');
|
||||||
|
const errorMsg = document.getElementById('error-msg');
|
||||||
|
const retryBtn = document.getElementById('retry-btn');
|
||||||
|
|
||||||
|
if (anyFailed) {
|
||||||
|
loadingText.className = 'loading-text error';
|
||||||
|
loadingText.textContent = 'Something went wrong';
|
||||||
|
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
|
||||||
|
errorMsg.classList.add('active');
|
||||||
|
retryBtn.classList.add('active');
|
||||||
|
document.getElementById('launch-overlay').classList.remove('active');
|
||||||
|
} else if (allReady) {
|
||||||
|
loadingText.className = 'loading-text';
|
||||||
|
loadingText.textContent = 'All systems ready!';
|
||||||
|
errorMsg.classList.remove('active');
|
||||||
|
retryBtn.classList.remove('active');
|
||||||
|
document.getElementById('launch-overlay').classList.add('active');
|
||||||
|
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
||||||
|
} else {
|
||||||
|
loadingText.className = 'loading-text';
|
||||||
|
const ready = CHECKS.filter(s => s.ready).length;
|
||||||
|
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > MAX_ATTEMPTS) {
|
||||||
|
for (const svc of CHECKS) {
|
||||||
|
if (!svc.ready) svc.failed = true;
|
||||||
|
}
|
||||||
|
updateUI();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkAllServices();
|
||||||
|
updateUI();
|
||||||
|
|
||||||
|
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
|
||||||
|
setTimeout(poll, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,15 @@ export default function DashboardPage() {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 relative">
|
||||||
<PageHeader title="Dashboard" description="Overview of your sales pipeline">
|
{/* Daily Bugle watermark */}
|
||||||
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
||||||
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
|
||||||
|
DAILY BUGLE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
@@ -78,7 +85,7 @@ export default function DashboardPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{stats
|
{stats
|
||||||
@@ -89,7 +96,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
|
||||||
import { chatWithAI } from "@/lib/ai"
|
import { chatWithAI } from "@/lib/ai"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { message } = await request.json()
|
const { message } = await request.json()
|
||||||
@@ -16,15 +14,16 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward the JWT from the session cookie to the Rust backend
|
const sessionCookie = request.cookies.get("session")
|
||||||
const sessionCookie = request.cookies.get("session")?.value
|
const jwtToken = sessionCookie?.value
|
||||||
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
|
if (!jwtToken) {
|
||||||
|
return NextResponse.json({ error: "No session token" }, { status: 401 })
|
||||||
const response = await chatWithAI(message, sessionCookie)
|
}
|
||||||
|
|
||||||
|
const response = await chatWithAI(message, jwtToken)
|
||||||
return NextResponse.json({ response })
|
return NextResponse.json({ response })
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error("AI chat error:", error)
|
console.error("AI chat error:", error)
|
||||||
return NextResponse.json({ error: "AI service unavailable" }, { status: 503 })
|
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
resetFailedAttempts,
|
resetFailedAttempts,
|
||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
createSession,
|
createSession,
|
||||||
|
setSessionContext,
|
||||||
} from "@/lib/auth"
|
} from "@/lib/auth"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
await createSession(dbUser.id, dbUser.role_name)
|
||||||
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import {
|
||||||
|
getSessionUser,
|
||||||
|
decryptPassword,
|
||||||
|
setSessionContext,
|
||||||
|
} from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId } = await request.json()
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: "userId is required." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"127.0.0.1"
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id, ipAddress)
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, username, email, first_name, last_name, password_encrypted
|
||||||
|
FROM users WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const user = result.rows[0]
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.password_encrypted) {
|
||||||
|
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintextPassword = await decryptPassword(user.password_encrypted)
|
||||||
|
if (!plaintextPassword) {
|
||||||
|
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
name: `${user.first_name} ${user.last_name}`,
|
||||||
|
},
|
||||||
|
password: plaintextPassword,
|
||||||
|
}, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Password recovery error:", error)
|
||||||
|
return NextResponse.json({ error: "Recovery service unavailable." }, { status: 503 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden. Only admins can update bug reports." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await routeParams
|
||||||
|
const { status, assigned_to, resolution_notes } = await request.json()
|
||||||
|
|
||||||
|
const ipAddress =
|
||||||
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||||
|
request.headers.get("x-real-ip") ||
|
||||||
|
"127.0.0.1"
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id, ipAddress)
|
||||||
|
|
||||||
|
const validStatuses = ["open", "in_progress", "resolved", "closed"]
|
||||||
|
if (status && !validStatuses.includes(status)) {
|
||||||
|
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: string[] = []
|
||||||
|
const values: unknown[] = []
|
||||||
|
let paramIndex = 1
|
||||||
|
|
||||||
|
if (status !== undefined) {
|
||||||
|
updates.push(`status = $${paramIndex++}`)
|
||||||
|
values.push(status)
|
||||||
|
}
|
||||||
|
if (assigned_to !== undefined) {
|
||||||
|
updates.push(`assigned_to = $${paramIndex++}`)
|
||||||
|
values.push(assigned_to === "null" ? null : assigned_to)
|
||||||
|
}
|
||||||
|
if (resolution_notes !== undefined) {
|
||||||
|
updates.push(`resolution_notes = $${paramIndex++}`)
|
||||||
|
values.push(resolution_notes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.push(`updated_at = NOW()`)
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE bug_reports SET ${updates.join(", ")} WHERE id = $${paramIndex}`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating bug report:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update bug report." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, severity, page_url, screenshot_url } = await request.json()
|
||||||
|
|
||||||
|
if (!title || !description) {
|
||||||
|
return NextResponse.json({ error: "Title and description are required." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validSeverities = ["low", "medium", "high", "critical"]
|
||||||
|
if (severity && !validSeverities.includes(severity)) {
|
||||||
|
return NextResponse.json({ error: "Invalid severity." }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO bug_reports (reported_by, title, description, severity, page_url, screenshot_url)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, created_at`,
|
||||||
|
[sessionUser.id, title, description, severity || "medium", page_url || null, screenshot_url || null]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
id: result.rows[0].id,
|
||||||
|
created_at: result.rows[0].created_at,
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error creating bug report:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to submit bug report." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const sessionUser = await getSessionUser()
|
||||||
|
if (!sessionUser) {
|
||||||
|
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||||
|
}
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden. Only admins can view bug reports." }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
const severity = searchParams.get("severity")
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
|
||||||
|
br.screenshot_url, br.status, br.resolution_notes,
|
||||||
|
br.created_at, br.updated_at,
|
||||||
|
reporter.id AS reporter_id,
|
||||||
|
reporter.first_name AS reporter_first_name,
|
||||||
|
reporter.last_name AS reporter_last_name,
|
||||||
|
reporter.email AS reporter_email,
|
||||||
|
assignee.id AS assignee_id,
|
||||||
|
assignee.first_name AS assignee_first_name,
|
||||||
|
assignee.last_name AS assignee_last_name
|
||||||
|
FROM bug_reports br
|
||||||
|
JOIN users reporter ON reporter.id = br.reported_by
|
||||||
|
LEFT JOIN users assignee ON assignee.id = br.assigned_to`
|
||||||
|
const params: unknown[] = []
|
||||||
|
const conditions: string[] = []
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
conditions.push(`br.status = $${params.length + 1}`)
|
||||||
|
params.push(status)
|
||||||
|
}
|
||||||
|
if (severity) {
|
||||||
|
conditions.push(`br.severity = $${params.length + 1}`)
|
||||||
|
params.push(severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
sql += " WHERE " + conditions.join(" AND ")
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY br.created_at DESC LIMIT $" + (params.length + 1) + " OFFSET $" + (params.length + 2)
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const result = await query(sql, params)
|
||||||
|
|
||||||
|
const reports = result.rows.map((row: any) => ({
|
||||||
|
id: row.id,
|
||||||
|
title: row.title,
|
||||||
|
description: row.description,
|
||||||
|
severity: row.severity,
|
||||||
|
page_url: row.page_url,
|
||||||
|
screenshot_url: row.screenshot_url,
|
||||||
|
status: row.status,
|
||||||
|
resolution_notes: row.resolution_notes,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
reporter: {
|
||||||
|
id: row.reporter_id,
|
||||||
|
name: `${row.reporter_first_name} ${row.reporter_last_name}`,
|
||||||
|
email: row.reporter_email,
|
||||||
|
},
|
||||||
|
assignee: row.assignee_id ? {
|
||||||
|
id: row.assignee_id,
|
||||||
|
name: `${row.assignee_first_name} ${row.assignee_last_name}`,
|
||||||
|
} : null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ reports }, { status: 200 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching bug reports:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to fetch bug reports." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,13 +116,28 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const msg = result.rows[0]
|
const msg = result.rows[0]
|
||||||
|
const senderName = `${user.firstName} ${user.lastName}`
|
||||||
|
|
||||||
|
const otherResult = await query(
|
||||||
|
`SELECT user_id FROM conversation_participants
|
||||||
|
WHERE conversation_id = $1 AND user_id != $2`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const row of otherResult.rows) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||||
|
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
|
||||||
|
[row.user_id, `${senderName} sent a message`, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: {
|
message: {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
senderId: user.id,
|
senderId: user.id,
|
||||||
senderName: `${user.firstName} ${user.lastName}`,
|
senderName,
|
||||||
senderAvatar: user.avatar,
|
senderAvatar: user.avatar,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
timestamp: formatTime(new Date(msg.created_at)),
|
timestamp: formatTime(new Date(msg.created_at)),
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export async function POST(
|
|||||||
[id, user.id],
|
[id, user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE notifications SET is_read = TRUE
|
||||||
|
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
||||||
|
[user.id, id],
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Mark read error:", error)
|
console.error("Mark read error:", error)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export async function GET() {
|
|||||||
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.phone AS other_user_phone,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
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,
|
||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function stageToStatus(name: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLeadsInRange(start: Date, end: Date) {
|
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||||
l.notes, l.assigned_to, l.score,
|
l.notes, l.assigned_to, l.score,
|
||||||
@@ -59,8 +59,11 @@ async function fetchLeadsInRange(start: Date, end: Date) {
|
|||||||
LEFT JOIN users u ON u.id = l.assigned_to
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
WHERE l.deleted_at IS NULL
|
WHERE l.deleted_at IS NULL
|
||||||
AND l.created_at >= $1 AND l.created_at <= $2
|
AND l.created_at >= $1 AND l.created_at <= $2
|
||||||
|
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
||||||
ORDER BY l.created_at DESC`,
|
ORDER BY l.created_at DESC`,
|
||||||
[start.toISOString(), end.toISOString()]
|
isAdmin
|
||||||
|
? [start.toISOString(), end.toISOString()]
|
||||||
|
: [start.toISOString(), end.toISOString(), userId]
|
||||||
)
|
)
|
||||||
return result.rows.map((r: any) => ({
|
return result.rows.map((r: any) => ({
|
||||||
...r,
|
...r,
|
||||||
@@ -118,6 +121,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const period = searchParams.get("period") || "6months"
|
const period = searchParams.get("period") || "6months"
|
||||||
const yearParam = searchParams.get("year")
|
const yearParam = searchParams.get("year")
|
||||||
@@ -134,8 +139,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
const [currentLeads, prevLeads] = await Promise.all([
|
||||||
fetchLeadsInRange(start, end),
|
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end),
|
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
const currentCounts = countStatuses(currentLeads)
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,28 @@ import { getSessionUser } from "@/lib/auth"
|
|||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
AND (assigned_to = $2 OR EXISTS (
|
||||||
|
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
|
||||||
|
))`,
|
||||||
|
[leadId, userId]
|
||||||
|
)
|
||||||
|
return result.rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
if (!await checkLeadAccess(id, user.id)) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
const { content } = await request.json()
|
const { content } = await request.json()
|
||||||
if (!content?.trim()) {
|
if (!content?.trim()) {
|
||||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||||
@@ -33,6 +49,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
if (!await checkLeadAccess(id, user.id)) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
||||||
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||||
if (body.assignedUserId !== undefined) {
|
if (body.assignedUserId !== undefined) {
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
if (!isAdmin) {
|
||||||
|
// non-admin cannot reassign
|
||||||
|
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
||||||
|
}
|
||||||
fields.push(`assigned_to = $${idx++}`)
|
fields.push(`assigned_to = $${idx++}`)
|
||||||
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
|
||||||
|
let assignedUserId = body.assignedUserId
|
||||||
|
if (!isAdmin) {
|
||||||
|
assignedUserId = user.id
|
||||||
|
} else if (assignedUserId === "none" || !assignedUserId) {
|
||||||
|
assignedUserId = null
|
||||||
|
}
|
||||||
|
|
||||||
const stageResult = await query(
|
const stageResult = await query(
|
||||||
"SELECT id FROM lead_stages WHERE name = $1",
|
"SELECT id FROM lead_stages WHERE name = $1",
|
||||||
@@ -142,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
body.description || null,
|
body.description || null,
|
||||||
body.source || null,
|
body.source || null,
|
||||||
stageId,
|
stageId,
|
||||||
body.assignedUserId === "none" ? null : body.assignedUserId,
|
assignedUserId,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function GET() {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT id, type, title, description, link, is_read, created_at
|
`SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
|
||||||
FROM notifications
|
FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -24,6 +24,8 @@ export async function GET() {
|
|||||||
link: r.link,
|
link: r.link,
|
||||||
read: r.is_read,
|
read: r.is_read,
|
||||||
timestamp: r.created_at,
|
timestamp: r.created_at,
|
||||||
|
contextId: r.context_id,
|
||||||
|
contextType: r.context_type,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unreadResult = await query(
|
const unreadResult = await query(
|
||||||
@@ -47,7 +49,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { type, title, description, link, userId } = await request.json()
|
const { type, title, description, link, userId } = await request.json()
|
||||||
const targetUserId = userId || user.id
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
const targetUserId = (userId && isAdmin) ? userId : user.id
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link)
|
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (body.isActive !== undefined) {
|
||||||
|
fields.push(`is_active = $${idx++}`)
|
||||||
|
values.push(body.isActive)
|
||||||
|
}
|
||||||
|
if (body.label !== undefined) {
|
||||||
|
fields.push(`label = $${idx++}`)
|
||||||
|
values.push(body.label.trim())
|
||||||
|
}
|
||||||
|
if (body.profilePath !== undefined) {
|
||||||
|
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
||||||
|
values.push(body.profilePath.trim())
|
||||||
|
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if (body.unflag === true) {
|
||||||
|
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`updated_at = NOW()`)
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
|
||||||
|
const updated = await query(
|
||||||
|
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
|
||||||
|
consecutive_failures, updated_at
|
||||||
|
FROM facebook_accounts WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(updated.rows[0] || { success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await query(
|
||||||
|
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
||||||
|
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
|
||||||
|
fa.last_error_message, fa.consecutive_failures,
|
||||||
|
fa.flagged, fa.flagged_at, fa.flagged_reason,
|
||||||
|
fa.created_at, fa.updated_at,
|
||||||
|
COALESCE(sl.leads_found, 0) AS last_leads_found,
|
||||||
|
sl.success AS last_success,
|
||||||
|
sl.detected_flag AS last_detected_flag
|
||||||
|
FROM facebook_accounts fa
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT leads_found, success, detected_flag
|
||||||
|
FROM facebook_scrape_logs
|
||||||
|
WHERE account_id = fa.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sl ON TRUE
|
||||||
|
ORDER BY fa.created_at ASC`
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(accounts.rows)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, profilePath } = await request.json()
|
||||||
|
if (!label?.trim() || !profilePath?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, label, profile_path, is_active, created_at`,
|
||||||
|
[label.trim(), profilePath.trim(), cookieFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(result.rows[0], { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const accountId = searchParams.get("accountId")
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
|
||||||
|
sl.started_at, sl.completed_at, sl.success,
|
||||||
|
sl.leads_found, sl.error_message, sl.detected_flag,
|
||||||
|
sl.created_at
|
||||||
|
FROM facebook_scrape_logs sl
|
||||||
|
JOIN facebook_accounts fa ON fa.id = sl.account_id`
|
||||||
|
const params: any[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
sql += ` WHERE sl.account_id = $${paramIdx++}`
|
||||||
|
params.push(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const result = await query(sql, params)
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook scrape logs GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||||
|
return NextResponse.json({ websiteTheme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Website theme GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const theme = body.websiteTheme || "spidey"
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
[user.id, JSON.stringify({ website_theme: theme })],
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, websiteTheme: theme })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Website theme PUT error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ let prevTime = Date.now()
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const elapsed = now - prevTime
|
const elapsed = now - prevTime
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
||||||
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -12,6 +15,18 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate decoded size: base64 is ~4/3 of original
|
||||||
|
const base64Data = avatar.split(",")[1] || ""
|
||||||
|
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
||||||
|
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||||
[avatar, user.id],
|
[avatar, user.id],
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
@@ -60,17 +63,20 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
const nameParts = name.trim().split(/\s+/)
|
const nameParts = name.trim().split(/\s+/)
|
||||||
const firstName = nameParts[0]
|
const firstName = nameParts[0]
|
||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
const username = email.split("@")[0]
|
const username = email.split("@")[0]
|
||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
|
const passwordEncrypted = await encryptPassword(password)
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
|
||||||
)
|
)
|
||||||
|
|
||||||
const roleId = (
|
const roleId = (
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await getSessionUser()
|
const currentUser = await getSessionUser()
|
||||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const q = request.nextUrl.searchParams.get("q") || ""
|
const q = request.nextUrl.searchParams.get("q") || ""
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+329
-12
@@ -1,7 +1,15 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(400%); }
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 210 40% 98%;
|
--background: 210 40% 98%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
@@ -9,7 +17,7 @@
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 0 100% 40%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
@@ -22,15 +30,16 @@
|
|||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 221.2 83.2% 53.3%;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 216 12% 92%;
|
||||||
--sidebar-foreground: 222.2 84% 4.9%;
|
--sidebar-foreground: 0 0% 20%;
|
||||||
--sidebar-primary: 221.2 83.2% 53.3%;
|
--sidebar-primary: 0 91.2% 59.8%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 210 40% 96.1%;
|
--sidebar-accent: 218 16% 87%;
|
||||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
--sidebar-accent-foreground: 0 0% 20%;
|
||||||
--sidebar-border: 214.3 31.8% 91.4%;
|
--sidebar-border: 220 11% 84%;
|
||||||
--sidebar-ring: 221.2 83.2% 53.3%;
|
--sidebar-ring: 0 76.3% 48%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--theme-primary: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -40,7 +49,7 @@
|
|||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 0 100% 53%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
@@ -52,15 +61,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 0 100% 53%;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 222.2 84% 4.9%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 217.2 91.2% 59.8%;
|
--sidebar-primary: 0 100% 53%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 224.3 76.3% 48%;
|
--sidebar-ring: 0 100% 53%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ocean {
|
.ocean {
|
||||||
@@ -315,6 +324,314 @@
|
|||||||
--sidebar-ring: 351 85% 52%;
|
--sidebar-ring: 351 85% 52%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ocean {
|
||||||
|
--primary: 187 75% 42%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 187 75% 42%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 187 75% 42%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 187 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 187 50% 20%;
|
||||||
|
--sidebar-border: 187 20% 88%;
|
||||||
|
--sidebar-ring: 187 75% 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.ocean {
|
||||||
|
--primary: 187 75% 50%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 187 75% 50%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 187 75% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 187 75% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forest {
|
||||||
|
--primary: 142 76% 36%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 142 76% 36%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 142 76% 36%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 142 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 142 50% 20%;
|
||||||
|
--sidebar-border: 142 20% 88%;
|
||||||
|
--sidebar-ring: 142 76% 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.forest {
|
||||||
|
--primary: 142 76% 44%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 142 76% 44%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 142 76% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 142 76% 44%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sunset {
|
||||||
|
--primary: 24 95% 53%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 24 95% 53%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 24 95% 53%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 24 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 24 50% 20%;
|
||||||
|
--sidebar-border: 24 20% 88%;
|
||||||
|
--sidebar-ring: 24 95% 53%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.sunset {
|
||||||
|
--primary: 24 95% 58%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 24 95% 58%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 24 95% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 24 95% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.midnight {
|
||||||
|
--primary: 230 75% 55%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 230 75% 55%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 230 75% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 230 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 230 50% 20%;
|
||||||
|
--sidebar-border: 230 20% 88%;
|
||||||
|
--sidebar-ring: 230 75% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.midnight {
|
||||||
|
--primary: 230 75% 62%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 230 75% 62%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 230 75% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 230 75% 62%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rose {
|
||||||
|
--primary: 346 77% 50%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 346 77% 50%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 346 77% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 346 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 346 50% 20%;
|
||||||
|
--sidebar-border: 346 20% 88%;
|
||||||
|
--sidebar-ring: 346 77% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.rose {
|
||||||
|
--primary: 346 77% 58%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 346 77% 58%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 346 77% 60%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 346 77% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amber {
|
||||||
|
--primary: 38 92% 50%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 38 92% 50%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 38 92% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 38 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 38 50% 20%;
|
||||||
|
--sidebar-border: 38 20% 88%;
|
||||||
|
--sidebar-ring: 38 92% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.amber {
|
||||||
|
--primary: 38 92% 56%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 38 92% 56%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 38 92% 60%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 38 92% 56%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.violet {
|
||||||
|
--primary: 262 83% 58%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 262 83% 58%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 262 83% 58%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 262 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 262 50% 20%;
|
||||||
|
--sidebar-border: 262 20% 88%;
|
||||||
|
--sidebar-ring: 262 83% 58%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.violet {
|
||||||
|
--primary: 262 83% 65%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 262 83% 65%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 262 83% 68%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 262 83% 65%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slate {
|
||||||
|
--primary: 215 20% 45%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 215 20% 45%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 215 20% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 215 20% 94%;
|
||||||
|
--sidebar-accent-foreground: 215 50% 20%;
|
||||||
|
--sidebar-border: 215 20% 88%;
|
||||||
|
--sidebar-ring: 215 20% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.slate {
|
||||||
|
--primary: 215 20% 60%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 215 20% 60%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 215 20% 65%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 215 20% 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ruby {
|
||||||
|
--primary: 351 85% 45%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
--ring: 351 85% 45%;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 351 85% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 351 30% 94%;
|
||||||
|
--sidebar-accent-foreground: 351 50% 20%;
|
||||||
|
--sidebar-border: 351 20% 88%;
|
||||||
|
--sidebar-ring: 351 85% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.ruby {
|
||||||
|
--primary: 351 85% 52%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--ring: 351 85% 52%;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 351 85% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-ring: 351 85% 52%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo {
|
||||||
|
font-family: 'Raleway', sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 18px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: hsl(var(--sidebar-foreground));
|
||||||
|
padding: 8px 16px;
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo .accent {
|
||||||
|
color: var(--theme-primary, #3b91f7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
border-top: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
border-left: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
animation: drawTL 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bc-logo::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
border-bottom: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
border-right: 1.5px solid var(--theme-primary, #3b91f7);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
animation: drawBR 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawTL {
|
||||||
|
0%, 5% { width: 0; height: 0; }
|
||||||
|
30% { width: 100%; height: 0; }
|
||||||
|
55% { width: 100%; height: 100%; }
|
||||||
|
85% { width: 100%; height: 100%; }
|
||||||
|
100% { width: 0; height: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drawBR {
|
||||||
|
0%, 5% { width: 0; height: 0; }
|
||||||
|
30% { width: 100%; height: 0; }
|
||||||
|
55% { width: 100%; height: 100%; }
|
||||||
|
85% { width: 100%; height: 100%; }
|
||||||
|
100% { width: 0; height: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
|
|||||||
@@ -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,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next"
|
import type { Metadata, Viewport } from "next"
|
||||||
import { Inter } from "next/font/google"
|
import { Inter } from "next/font/google"
|
||||||
import { ThemeProvider } from "@/providers/theme-provider"
|
import { ThemeProvider } from "@/providers/theme-provider"
|
||||||
|
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
||||||
import { Toaster } from "@/components/ui/sonner"
|
import { Toaster } from "@/components/ui/sonner"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
|
|
||||||
@@ -31,8 +32,10 @@ export default function RootLayout({
|
|||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||||
|
<WebsiteThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
</WebsiteThemeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+9
-14
@@ -1,8 +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 { Eye, EyeOff, Loader2 } from "lucide-react"
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||||
|
|
||||||
const waves = [
|
const waves = [
|
||||||
@@ -14,7 +13,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)
|
||||||
@@ -28,11 +28,11 @@ export default function LoginPage() {
|
|||||||
const testimonials = [
|
const testimonials = [
|
||||||
{
|
{
|
||||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||||
author: "Marcus Johnson, Sales Lead at Coast IT",
|
author: "Marcus Johnson, Sales Lead at Black Cipher",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||||
author: "Dillen van der Merwe, Madman of Coast IT",
|
author: "Dillen van der Merwe, Madman of Black Cipher",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -215,7 +215,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.")
|
||||||
@@ -230,16 +230,11 @@ export default function LoginPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||||
<div className="left-panel flex flex-1 flex-col p-12">
|
<div className="left-panel flex flex-1 flex-col p-12">
|
||||||
<div className="relative z-10">
|
|
||||||
<img
|
|
||||||
src="/logo/CompanyLogo.png"
|
|
||||||
alt={COMPANY_NAME}
|
|
||||||
className="h-44 w-auto object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"}}>
|
||||||
|
Black <span className="accent">Cipher</span>
|
||||||
|
</div>
|
||||||
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||||
Your Agency's{" "}
|
Your Agency's{" "}
|
||||||
<span className="growth-word">Growth</span>{" "}
|
<span className="growth-word">Growth</span>{" "}
|
||||||
|
|||||||
+110
-32
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment } from "react"
|
import { useState, useRef, useEffect, Fragment } from "react"
|
||||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
|
||||||
|
|
||||||
function linkifyText(text: string) {
|
function linkifyText(text: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||||
@@ -24,9 +24,26 @@ export function AIChat() {
|
|||||||
const [input, setInput] = useState("")
|
const [input, setInput] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const checkServer = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: "ping" }),
|
||||||
|
})
|
||||||
|
if (res.status !== 503) {
|
||||||
|
setBootState("ready")
|
||||||
|
} else {
|
||||||
|
setTimeout(checkServer, 2000)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setTimeout(checkServer, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -56,29 +73,13 @@ export function AIChat() {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
checkServer()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const checkOllama = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/ai/chat", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ message: "__ping__" }),
|
|
||||||
})
|
|
||||||
setOllamaStatus(res.status !== 503)
|
|
||||||
} catch {
|
|
||||||
setOllamaStatus(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { checkOllama() }, [])
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
const msg = input.trim()
|
const msg = input.trim()
|
||||||
if (!msg || loading) return
|
if (!msg || loading) return
|
||||||
@@ -96,8 +97,8 @@ export function AIChat() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json().catch(() => ({}))
|
||||||
throw new Error(data.error || "Failed to get response")
|
throw new Error(data.error || `Error ${res.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -121,17 +122,86 @@ export function AIChat() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bootOverlay = (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<style>{`
|
||||||
|
@keyframes walk {
|
||||||
|
0%, 100% { transform: translateX(0) translateY(0); }
|
||||||
|
25% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
50% { transform: translateX(24px) translateY(0); }
|
||||||
|
75% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
}
|
||||||
|
@keyframes legLeft {
|
||||||
|
0%, 100% { transform: rotate(-10deg); }
|
||||||
|
50% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
@keyframes legRight {
|
||||||
|
0%, 100% { transform: rotate(10deg); }
|
||||||
|
50% { transform: rotate(-10deg); }
|
||||||
|
}
|
||||||
|
@keyframes armLeft {
|
||||||
|
0%, 100% { transform: rotate(15deg); }
|
||||||
|
50% { transform: rotate(-15deg); }
|
||||||
|
}
|
||||||
|
@keyframes armRight {
|
||||||
|
0%, 100% { transform: rotate(-15deg); }
|
||||||
|
50% { transform: rotate(15deg); }
|
||||||
|
}
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 45%, 55%, 100% { height: 4px; }
|
||||||
|
50% { height: 1px; }
|
||||||
|
}
|
||||||
|
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||||
|
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||||
|
`}</style>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
|
||||||
|
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
|
||||||
|
<div className="robot-walk relative">
|
||||||
|
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||||
|
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
|
||||||
|
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
|
||||||
|
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
|
||||||
|
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||||
|
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||||
|
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||||
|
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||||
|
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
|
||||||
|
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
|
||||||
|
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const readyOverlay = (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
|
||||||
|
<Check className="h-6 w-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-1">
|
||||||
|
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
|
||||||
|
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (bootState === "booting") return bootOverlay
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{ollamaStatus === false && (
|
{bootState === "ready" && readyOverlay}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
|
||||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
|
||||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
|
||||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
@@ -163,7 +233,10 @@ export function AIChat() {
|
|||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -192,7 +265,12 @@ export function AIChat() {
|
|||||||
disabled={loading || !input.trim()}
|
disabled={loading || !input.trim()}
|
||||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
{loading ? (
|
||||||
|
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
) : <Send className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -68,11 +68,24 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
|||||||
transition={{ duration: 0.3, delay: 0.2 }}
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<Card className="h-full">
|
<Card className="h-full relative overflow-hidden">
|
||||||
|
{/* Spider watermark */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
|
||||||
|
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
|
||||||
|
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
|
||||||
|
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||||
|
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||||
|
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
|
||||||
|
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
|
||||||
|
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Lead Status</CardTitle>
|
<CardTitle>Lead Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 flex-col">
|
<CardContent className="flex flex-1 flex-col relative z-10">
|
||||||
<div className="flex flex-1 flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center">
|
||||||
{/* Donut */}
|
{/* Donut */}
|
||||||
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
|
|||||||
data: IntervalData[]
|
data: IntervalData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEW_LEADS = "#0d9488"
|
const NEW_LEADS = "#CC0000"
|
||||||
const CLOSED = "#c9a96e"
|
const CLOSED = "#0033CC"
|
||||||
|
|
||||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||||
const [year, setYear] = useState(new Date().getFullYear())
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#0d9488" />
|
<stop offset="0%" stopColor="#FF1111" />
|
||||||
<stop offset="100%" stopColor={NEW_LEADS} />
|
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#e8d5a3" />
|
<stop offset="0%" stopColor="#1144FF" />
|
||||||
<stop offset="100%" stopColor={CLOSED} />
|
<stop offset="100%" stopColor={CLOSED} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadowNew">
|
<filter id="shadowNew">
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
|
|
||||||
export function StatCardSkeleton() {
|
export function StatCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
<CardContent className="p-6 flex flex-col">
|
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
||||||
<div className="flex items-center justify-between">
|
THWIP!
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Skeleton className="h-4 w-24" />
|
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
||||||
<Skeleton className="h-8 w-16" />
|
Loading your data...
|
||||||
|
</p>
|
||||||
|
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||||
|
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-12 w-12 rounded-xl" />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ interface StatCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||||
"Total Leads": { bg: "bg-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" },
|
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" },
|
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" },
|
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" },
|
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" },
|
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" },
|
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeGoal(max: number): number {
|
function computeGoal(max: number): number {
|
||||||
@@ -107,6 +107,10 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||||
const sparkColor = color.accent
|
const sparkColor = color.accent
|
||||||
|
|
||||||
|
const isRed = index % 2 === 0
|
||||||
|
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
||||||
|
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -114,16 +118,50 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<Card className="group h-full hover:shadow-md transition-all duration-200">
|
{/* Web overlay decorative */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comic action lines on hover */}
|
||||||
|
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
|
||||||
|
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||||
|
{/* Red/Blue gradient top strip */}
|
||||||
|
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||||
|
|
||||||
<CardContent className="p-6 flex flex-col">
|
<CardContent className="p-6 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
<p className="text-3xl font-bold tracking-tight">
|
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
||||||
{isNumeric ? display : value}
|
{isNumeric ? display : value}
|
||||||
</p>
|
</p>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
||||||
)}
|
)}
|
||||||
{trend && (
|
{trend && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -134,8 +172,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}>
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
|
||||||
<Icon className={cn("h-6 w-6", color.text)} />
|
<Icon className={`h-6 w-6 ${color.text}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,9 +217,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div className="h-full rounded-full bg-gradient-to-r from-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{conversionRate}% conversion rate</p>
|
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation"
|
|||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { Sidebar } from "./sidebar"
|
import { Sidebar } from "./sidebar"
|
||||||
import { Topbar } from "./topbar"
|
import { Topbar } from "./topbar"
|
||||||
import { SystemMonitor } from "./system-monitor"
|
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
@@ -34,8 +33,52 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||||
<SystemMonitor />
|
style={{
|
||||||
|
backgroundImage: "radial-gradient(circle, #e6202010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||||
|
backgroundSize: "28px 28px, 14px 14px",
|
||||||
|
backgroundPosition: "0 0, 7px 7px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Spider-Man top gradient bar */}
|
||||||
|
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#e62020] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF6666] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||||
|
|
||||||
|
{/* Corner spider webs */}
|
||||||
|
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]">
|
||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="0" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="60" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="120" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="180" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="120" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="60" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<line x1="0" y1="0" x2="0" y2="180" stroke="#e62020" strokeWidth="0.8"/>
|
||||||
|
<path d="M0,30 Q30,30 30,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,60 Q60,60 60,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,90 Q90,90 90,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,120 Q120,120 120,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,150 Q150,150 150,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
<path d="M0,180 Q180,180 180,0" stroke="#e62020" strokeWidth="0.8" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={toggleSidebar}
|
onToggle={toggleSidebar}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link"
|
|||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import { motion, AnimatePresence } from "framer-motion"
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
import { SystemMonitor } from "./system-monitor"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||||
@@ -18,10 +19,12 @@ import {
|
|||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bot,
|
Bot,
|
||||||
|
Facebook,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { useNotifications } from "@/providers/notification-provider"
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
|
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
@@ -43,7 +46,9 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { unreadChatCount } = useNotifications()
|
const { unreadChatCount } = useNotifications()
|
||||||
|
const [fbDialogOpen, setFbDialogOpen] = useState(false)
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
@@ -56,24 +61,11 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
||||||
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
||||||
<img
|
{collapsed ? (
|
||||||
src="/logo/CompanyLogo.png"
|
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
|
||||||
alt={COMPANY_NAME}
|
) : (
|
||||||
className="h-8 w-8 shrink-0 rounded-lg object-contain"
|
<div className="bc-logo">Black<span className="accent">Cipher</span></div>
|
||||||
/>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!collapsed && (
|
|
||||||
<motion.span
|
|
||||||
initial={{ opacity: 0, x: -10 }}
|
|
||||||
animate={{ opacity: 1, x: 0 }}
|
|
||||||
exit={{ opacity: 0, x: -10 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
className="text-sm font-semibold whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{COMPANY_NAME}
|
|
||||||
</motion.span>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
</Link>
|
</Link>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<Button
|
<Button
|
||||||
@@ -143,6 +135,39 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{isAdmin && collapsed && (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => setFbDialogOpen(true)}
|
||||||
|
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Facebook className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="ml-2">
|
||||||
|
Facebook Accounts
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{isAdmin && !collapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFbDialogOpen(true)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Facebook className="h-5 w-5 shrink-0" />
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="truncate"
|
||||||
|
>
|
||||||
|
Facebook Accounts
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse toggle at bottom (only in collapsed mode) */}
|
{/* Collapse toggle at bottom (only in collapsed mode) */}
|
||||||
@@ -159,6 +184,8 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SystemMonitor collapsed={collapsed} />
|
||||||
|
|
||||||
{/* User info */}
|
{/* User info */}
|
||||||
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -185,10 +212,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop sidebar */}
|
{/* Desktop sidebar */}
|
||||||
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex">
|
<aside className="hidden lg:fixed lg:inset-y-0 lg:z-30 lg:flex border-r border-sidebar-border">
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<FacebookAccountsDialog open={fbDialogOpen} onOpenChange={setFbDialogOpen} />
|
||||||
|
|
||||||
{/* Mobile sidebar overlay */}
|
{/* Mobile sidebar overlay */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
@@ -205,7 +234,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
animate={{ x: 0 }}
|
animate={{ x: 0 }}
|
||||||
exit={{ x: -300 }}
|
exit={{ x: -300 }}
|
||||||
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
transition={{ type: "spring", damping: 30, stiffness: 300 }}
|
||||||
className="fixed inset-y-0 left-0 z-50 lg:hidden"
|
className="fixed inset-y-0 left-0 z-50 lg:hidden border-r border-sidebar-border"
|
||||||
>
|
>
|
||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</motion.aside>
|
</motion.aside>
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
|
|
||||||
const RAM_LIMIT_MB = 8192
|
const RAM_LIMIT_MB = 8192
|
||||||
const CPU_LIMIT_PCT = 400 // 4 cores * 100%
|
const CPU_LIMIT_PCT = 400
|
||||||
|
|
||||||
export function SystemMonitor() {
|
interface SystemMonitorProps {
|
||||||
|
collapsed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemMonitor({ collapsed }: SystemMonitorProps) {
|
||||||
const [rssMB, setRssMB] = useState(0)
|
const [rssMB, setRssMB] = useState(0)
|
||||||
const [cpuPct, setCpuPct] = useState(0)
|
const [cpuPct, setCpuPct] = useState(0)
|
||||||
|
|
||||||
@@ -30,14 +34,33 @@ export function SystemMonitor() {
|
|||||||
const ramOver = rssMB > RAM_LIMIT_MB
|
const ramOver = rssMB > RAM_LIMIT_MB
|
||||||
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
const cpuOver = cpuPct > CPU_LIMIT_PCT
|
||||||
|
|
||||||
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-0 left-0 z-[9999] flex items-center gap-3 px-3 py-1 text-[11px] font-mono bg-black/80 rounded-br-lg select-none">
|
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5">
|
||||||
<span className={ramOver ? "text-red-400" : "text-green-400"}>
|
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
|
||||||
RAM: {rssMB}MB / 8192MB
|
{rssMB}
|
||||||
</span>
|
</span>
|
||||||
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
|
<span className={cpuOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
|
||||||
CPU: {cpuPct}%
|
{cpuPct}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-sidebar-border px-3 py-2.5">
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono">
|
||||||
|
<span className="text-sidebar-foreground/50">RAM</span>
|
||||||
|
<span className={ramOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
|
||||||
|
{rssMB}MB / {RAM_LIMIT_MB}MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||||
|
<span className="text-sidebar-foreground/50">CPU</span>
|
||||||
|
<span className={cpuOver ? "text-red-400 font-semibold" : "text-sidebar-foreground/80"}>
|
||||||
|
{cpuPct}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useUser } from "@/providers/user-provider";
|
import { useUser } from "@/providers/user-provider";
|
||||||
import { useNotifications } from "@/providers/notification-provider";
|
import { useNotifications } from "@/providers/notification-provider";
|
||||||
|
import { BugReportModal } from "@/components/shared/bug-report-modal";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Settings,
|
Settings,
|
||||||
|
Bug,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -43,6 +45,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
useNotifications();
|
useNotifications();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [bugModalOpen, setBugModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -67,11 +70,18 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||||
|
{/* Red/Blue diagonal split accent */}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-[6px] bg-gradient-to-b from-[#CC0000] via-[#0033CC] to-[#CC0000] dark:from-[#FF1111] dark:via-[#1144FF] dark:to-[#FF1111]" />
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||||
<div className="w-3 h-3 rounded-full bg-[#0d9488]" />
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<span className="text-[#0d9488] font-bold text-sm tracking-wide">
|
<circle cx="14" cy="14" r="13" fill="#CC0000" />
|
||||||
|
<circle cx="14" cy="14" r="8" fill="#0033CC" />
|
||||||
|
<path d="M14 6 L16 12 L22 12 L17 15 L19 21 L14 17 L9 21 L11 15 L6 12 L12 12 Z" fill="white" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||||
CRM
|
CRM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,10 +98,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
<div className="relative hidden flex-1 sm:block md:w-80">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads, companies..."
|
placeholder="Search leads, companies..."
|
||||||
className="h-9 w-full max-w-sm pl-9 bg-muted/50"
|
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,6 +116,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
<Search className="h-5 w-5" />
|
<Search className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Report a Bug */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setBugModalOpen(true)}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
title="Report a Bug"
|
||||||
|
>
|
||||||
|
<Bug className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
{/* Theme toggle */}
|
{/* Theme toggle */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -240,6 +261,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { ShieldAlert, RefreshCw } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
is_active: boolean
|
||||||
|
last_scrape_at: string | null
|
||||||
|
last_success_at: string | null
|
||||||
|
last_error_at: string | null
|
||||||
|
consecutive_failures: number
|
||||||
|
flagged: boolean
|
||||||
|
flagged_reason: string | null
|
||||||
|
last_leads_found: number
|
||||||
|
last_success: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FacebookAccountsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) load()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/facebook/accounts")
|
||||||
|
if (res.ok) {
|
||||||
|
setAccounts(await res.json())
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle>Facebook Accounts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Status of Facebook profiles used for lead scraping
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={load} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Loading...</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No accounts configured</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Scrape</TableHead>
|
||||||
|
<TableHead>Leads</TableHead>
|
||||||
|
<TableHead>Failures</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-medium">{a.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{a.flagged ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
{a.flagged_reason || "Flagged"}
|
||||||
|
</Badge>
|
||||||
|
) : a.is_active ? (
|
||||||
|
<Badge variant="default" className="bg-green-600">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Disabled</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{a.last_scrape_at ? new Date(a.last_scrape_at).toLocaleString() : "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{a.last_leads_found}</TableCell>
|
||||||
|
<TableCell>{a.consecutive_failures}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Sun, Moon, Monitor } from "lucide-react"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
import { Sun, Moon, Monitor, Check } from "lucide-react"
|
||||||
|
|
||||||
const COLOR_THEME_KEY = "crm-color-theme"
|
const COLOR_THEME_KEY = "crm-color-theme"
|
||||||
|
|
||||||
@@ -44,8 +45,17 @@ function applyColorTheme(theme: string) {
|
|||||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const websiteThemes = [
|
||||||
|
{
|
||||||
|
id: "spidey",
|
||||||
|
name: "Spidey",
|
||||||
|
description: "Current website theme",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
export function ThemeSettings() {
|
export function ThemeSettings() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
||||||
const [colorTheme, setColorTheme] = useState("default")
|
const [colorTheme, setColorTheme] = useState("default")
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
@@ -119,6 +129,71 @@ export function ThemeSettings() {
|
|||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Website Themes</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Select your website's visual theme.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
|
||||||
|
{websiteThemes.map((wt) => {
|
||||||
|
const isActive = websiteTheme === wt.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={wt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setWebsiteTheme(wt.id)}
|
||||||
|
className={cn(
|
||||||
|
"group relative flex flex-col items-start gap-3 rounded-xl border-2 p-4 text-left transition-all",
|
||||||
|
"hover:bg-accent cursor-pointer",
|
||||||
|
isActive
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-border hover:border-muted-foreground/30"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isActive && (
|
||||||
|
<span className="absolute right-2 top-2 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] text-primary-foreground">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex h-20 w-full items-center justify-center overflow-hidden rounded-lg border border-border bg-background">
|
||||||
|
<div className="flex h-full w-full">
|
||||||
|
<div className="flex w-1/3 flex-col gap-0.5 bg-[#0a0a0f] p-1.5">
|
||||||
|
<div className="h-1 w-full rounded-sm bg-[#1a1a24]" />
|
||||||
|
<div className="h-1 w-2/3 rounded-sm bg-[#1a1a24]" />
|
||||||
|
<div className="mt-auto h-1.5 w-full rounded-sm bg-[#1a1a24]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<div className="flex h-5 items-center gap-1 bg-[#CC0000] px-1.5">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-white/30" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 items-center justify-center bg-[#141414]">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-[#CC0000]/40" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<span className="text-sm font-medium">{wt.name}</span>
|
||||||
|
{isActive && (
|
||||||
|
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold text-primary">
|
||||||
|
Current
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { motion, AnimatePresence } from "framer-motion"
|
||||||
|
import { X, Bug, Loader2, CheckCircle } from "lucide-react"
|
||||||
|
|
||||||
|
interface BugReportModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||||
|
const [title, setTitle] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [severity, setSeverity] = useState("medium")
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError("")
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bug-reports", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
page_url: window.location.href,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSubmitted(true)
|
||||||
|
setTitle("")
|
||||||
|
setDescription("")
|
||||||
|
setSeverity("medium")
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
setError(data.error || "Failed to submit bug report.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Connection error. Please try again.")
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSubmitted(false)
|
||||||
|
setError("")
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{open && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||||
|
onClick={handleClose}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
||||||
|
className="relative w-full max-w-md rounded-xl border bg-white dark:bg-[#141414] p-6 shadow-2xl"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="flex flex-col items-center py-8 text-center">
|
||||||
|
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
||||||
|
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
|
||||||
|
Bug Report Submitted
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
|
||||||
|
Thank you for your report. The development team will investigate.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||||
|
<Bug className="h-5 w-5 text-[#CC0000]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
|
||||||
|
Report a Bug
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
|
||||||
|
Help us improve the system
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 rounded-lg bg-red-500/10 px-4 py-2 text-sm text-red-500">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Title <span className="text-[#CC0000]">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Brief description of the issue"
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Description <span className="text-[#CC0000]">*</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What happened? What did you expect?"
|
||||||
|
rows={4}
|
||||||
|
required
|
||||||
|
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||||
|
Severity
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={severity}
|
||||||
|
onChange={(e) => setSeverity(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||||
|
>
|
||||||
|
<option value="low">Low — Minor cosmetic issue</option>
|
||||||
|
<option value="medium">Medium — Affects functionality</option>
|
||||||
|
<option value="high">High — Major feature broken</option>
|
||||||
|
<option value="critical">Critical — System is blocked</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
|
||||||
|
<p className="text-xs text-[#888888]">
|
||||||
|
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{loading ? "Submitting..." : "Submit Bug Report"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
|
|||||||
className={cn("flex items-center justify-between pb-6", className)}
|
className={cn("flex items-center justify-between pb-6", className)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#0d9488] pl-4">{title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
|
||||||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="flex items-center gap-3">{children}</div>}
|
{children && <div className="flex items-center gap-3">{children}</div>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: "Open", value: statusCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: statusCounts.open, color: "#CC0000" },
|
||||||
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
|
||||||
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
|
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
|
||||||
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
|
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" },
|
||||||
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" },
|
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
+61
-26
@@ -1,41 +1,76 @@
|
|||||||
|
import http from "http"
|
||||||
|
|
||||||
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
|
||||||
|
|
||||||
|
function parseUrl(url: string) {
|
||||||
|
const u = new URL(url)
|
||||||
|
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
|
||||||
|
}
|
||||||
|
|
||||||
export async function chatWithAI(message: string, jwtToken: string) {
|
export async function chatWithAI(message: string, jwtToken: string) {
|
||||||
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
|
const body = JSON.stringify({ message })
|
||||||
method: "POST",
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
|
||||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
|
|
||||||
body: JSON.stringify({ message }),
|
const text = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
|
||||||
|
(res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
|
res.on("end", () => {
|
||||||
|
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(data)
|
||||||
|
} else {
|
||||||
|
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
req.on("error", reject)
|
||||||
|
req.write(body)
|
||||||
|
req.end()
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!res.ok) {
|
const data = JSON.parse(text)
|
||||||
const text = await res.text()
|
|
||||||
throw new Error(`AI service error (${res.status}): ${text}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
return data.response || ""
|
return data.response || ""
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchJobs() {
|
export async function checkAiServiceStatus() {
|
||||||
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
|
await new Promise<void>((resolve, reject) => {
|
||||||
if (!res.ok) return []
|
const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
|
||||||
const data = await res.json()
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
|
res.on("end", () => {
|
||||||
|
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchJobs() {
|
||||||
|
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
|
||||||
|
try {
|
||||||
|
const text = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
|
||||||
|
let data = ""
|
||||||
|
res.on("data", (chunk) => data += chunk)
|
||||||
|
res.on("end", () => resolve(data))
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||||
|
})
|
||||||
|
const data = JSON.parse(text)
|
||||||
return data.jobs || []
|
return data.jobs || []
|
||||||
} catch {
|
} catch {
|
||||||
console.warn("Failed to fetch AI jobs")
|
console.warn("Failed to fetch AI jobs")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkAiServiceStatus() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${AI_SERVICE}/health`)
|
|
||||||
if (!res.ok) return false
|
|
||||||
const data = await res.json()
|
|
||||||
return data.status === "ok"
|
|
||||||
} catch {
|
|
||||||
console.warn("Failed to check AI service status")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+24
-2
@@ -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(),
|
||||||
@@ -192,6 +194,27 @@ export function mapDbUserToSessionUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function encryptPassword(password: string): Promise<string> {
|
||||||
|
const result = await query("SELECT encrypt_password($1) AS encrypted", [password]);
|
||||||
|
return result.rows[0]?.encrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPassword(encrypted: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const result = await query("SELECT decrypt_password($1) AS decrypted", [encrypted]);
|
||||||
|
return result.rows[0]?.decrypted || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSessionContext(userId: string, ip?: string) {
|
||||||
|
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
|
||||||
|
if (ip) {
|
||||||
|
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSession(userId: string, role: string) {
|
export async function createSession(userId: string, role: string) {
|
||||||
const token = await signToken({ userId, role });
|
const token = await signToken({ userId, role });
|
||||||
|
|
||||||
@@ -201,7 +224,6 @@ export async function createSession(userId: string, role: string) {
|
|||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
sameSite: "strict",
|
sameSite: "strict",
|
||||||
path: "/",
|
path: "/",
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ export const LEAD_SOURCES = [
|
|||||||
|
|
||||||
export const ITEMS_PER_PAGE = 10
|
export const ITEMS_PER_PAGE = 10
|
||||||
|
|
||||||
export const COMPANY_NAME = "Coast IT"
|
export const COMPANY_NAME = "Black Cipher"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||||
|
|
||||||
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
|
const themeClasses: Record<string, string> = {
|
||||||
|
spidey: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebsiteThemeContextValue {
|
||||||
|
websiteTheme: string
|
||||||
|
setWebsiteTheme: (theme: string) => Promise<void>
|
||||||
|
loading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const WebsiteThemeContext = createContext<WebsiteThemeContextValue | null>(null)
|
||||||
|
|
||||||
|
function applyWebsiteTheme(theme: string) {
|
||||||
|
const prefix = "theme-"
|
||||||
|
const root = document.documentElement
|
||||||
|
const classes = root.className.split(" ").filter((c) => !c.startsWith(prefix))
|
||||||
|
const cls = themeClasses[theme]
|
||||||
|
if (cls) {
|
||||||
|
classes.push(cls)
|
||||||
|
}
|
||||||
|
root.className = classes.join(" ").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredTheme(): string | null {
|
||||||
|
if (typeof window === "undefined") return null
|
||||||
|
return localStorage.getItem(WEBSITE_THEME_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTheme(theme: string) {
|
||||||
|
if (typeof window === "undefined") return
|
||||||
|
localStorage.setItem(WEBSITE_THEME_KEY, theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = getStoredTheme()
|
||||||
|
if (stored) {
|
||||||
|
setWebsiteThemeState(stored)
|
||||||
|
applyWebsiteTheme(stored)
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetch("/api/settings/website-theme")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then((data) => {
|
||||||
|
const theme = data?.websiteTheme || "spidey"
|
||||||
|
setWebsiteThemeState(theme)
|
||||||
|
storeTheme(theme)
|
||||||
|
applyWebsiteTheme(theme)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
applyWebsiteTheme("spidey")
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setWebsiteTheme = useCallback(async (theme: string) => {
|
||||||
|
setWebsiteThemeState(theme)
|
||||||
|
storeTheme(theme)
|
||||||
|
applyWebsiteTheme(theme)
|
||||||
|
try {
|
||||||
|
await fetch("/api/settings/website-theme", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ websiteTheme: theme }),
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
console.warn("Failed to persist website theme")
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
|
||||||
|
{children}
|
||||||
|
</WebsiteThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebsiteTheme() {
|
||||||
|
const ctx = useContext(WebsiteThemeContext)
|
||||||
|
if (!ctx) throw new Error("useWebsiteTheme must be used within WebsiteThemeProvider")
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
+2
-1
@@ -4,12 +4,13 @@ export type LeadStatus =
|
|||||||
| "pending"
|
| "pending"
|
||||||
| "closed"
|
| "closed"
|
||||||
| "ignored";
|
| "ignored";
|
||||||
export type UserRole = "super_admin" | "admin" | "sales";
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
export interface User {
|
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