Compare commits

...

30 Commits

Author SHA1 Message Date
caitlin ec8207fca7 Update login page styling and sidebar collapse logo 2026-06-26 16:22:28 +02:00
caitlin 33a4f9dfa9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:48:02 +02:00
Hannah_Bagga 82e8ce8ae9 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:21:45 +02:00
Hannah_Bagga 343f814569 npm 2026-06-26 11:21:10 +02:00
TroodonEnjoyer 4c09635d30 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 11:14:08 +02:00
TroodonEnjoyer 20a1744e7f Security architecture upgrade + bug reporting system
Database & Security:
- Dual password storage: bcrypt (auth) + pgcrypto AES-256 (recovery)
- SUPER_ADMIN master key recovery system (master_keys table)
- Row Level Security on customers, leads, opportunities, communications, tasks
  - SALES_USER: own records only
  - ADMIN: all records
  - SUPER_ADMIN: all records (bypasses RLS)
- Immutable audit logs (DELETE/UPDATE blocked by triggers)
- New audit event types: BUG_CREATED, BUG_UPDATED, BUG_ASSIGNED,
  BUG_RESOLVED, LOGIN, LOGOUT
- Database export logging (database_export_logs table)
- Backup logging with pg_dump script (scripts/backup.ps1)
- Fixed audit constraint to allow new action types

Authentication:
- Random JWT secret generated on every dev server start
  (invalidates all prior sessions after restart)
- Session cookie is now session-only (no maxAge)
- setSessionContext() for RLS integration

Bug Reporting System:
- bug_reports table with RLS (insert by all, select/update by admin only)
- POST /api/bug-reports (any authenticated user)
- GET /api/bug-reports (admin/super_admin only)
- PATCH /api/bug-reports/:id (admin/super_admin only)
- POST /api/auth/recover (super_admin password recovery)
- Audit logging for all bug report actions

Other:
- Added 'dev' to UserRole type
- Bug report modal UI with severity selector
- Added bug report button to topbar
2026-06-26 11:13:28 +02:00
Ace c9df7787d5 Added in loading bot 2026-06-26 11:10:58 +02:00
caitlin fa39f2af9d Merge origin/main into master 2026-06-26 09:48:11 +02:00
Ace 9bbaf70145 Checks done 2026-06-25 16:04:05 +02:00
Hannah_Bagga 5668a63370 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-25 15:50:35 +02:00
Ace 3af622f53d added browser 2026-06-25 15:42:35 +02:00
Hannah_Bagga 1465016b56 ..... 2026-06-25 15:15:35 +02:00
Hannah_Bagga 906e37e845 .... 2026-06-25 15:15:15 +02:00
caitlin 52a489759f Working on the mcp, this helps connect it 2026-06-25 10:25:00 +02:00
Ace 1f032dc098 Added rene changes 2026-06-24 18:43:38 +02:00
Ace 1adb6544c2 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-24 17:35:58 +02:00
Ace 56ff9995a2 Restore file structure to last legit commit b245b35, remove remote CRM_ENVR-main files 2026-06-24 17:35:48 +02:00
TroodonEnjoyer 33753a1291 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-24 17:35:10 +02:00
Ace 06912f3e59 Merge remote changes, keep local project structure 2026-06-24 17:29:07 +02:00
Ace f7258d38f6 Seeing Ai new added 2026-06-24 17:26:32 +02:00
Ace 4c1d994054 fix 2026-06-24 17:21:52 +02:00
Ace b245b352e7 Updated Ai to pull from facebook, added a loading SVG to make user wait for server too boot up, and added commands that can be used to start off the AI exstraction 2026-06-24 17:17:38 +02:00
Rene cd797c5b3c Remove node_modules from tracking 2026-06-24 15:21:57 +02:00
Rene 9d9309833b Add root .gitignore to exclude node_modules and .next 2026-06-24 15:21:49 +02:00
Rene 1e332fb50d Fix JWT_SECRET env var, setup database, update spider web and DAILY BUGLE colors to #e62020 2026-06-24 15:08:48 +02:00
TroodonEnjoyer 9acbbb6a19 Fixed the problem with skipping the login screen and /dashboard 2026-06-24 13:15:04 +02:00
Ace 8a6ad5c6c6 Updated 2026-06-24 12:30:22 +02:00
Ace b2a2f7e40f Ahh fix bloating size upload was to large 2026-06-24 09:54:28 +02:00
Ace 2b4749e5e1 Add rust-ai/target/ to gitignore, remove build artifacts from tracking 2026-06-24 08:58:18 +02:00
Ace e872ab9736 feat: Enhance dashboard UI with Spider-Man theme
- Added Spider-Man themed watermarks and gradients to the dashboard components.
- Updated color scheme for various dashboard elements to match the Spider-Man theme.
- Introduced new styles and animations for loading states and visual elements.
- Refactored existing components to incorporate the new design elements, including charts and cards.
- Updated global styles to support the new theme and added custom fonts.
- Adjusted layout and responsiveness for better user experience.
2026-06-23 20:24:27 +02:00
53 changed files with 5645 additions and 574 deletions
+3 -9
View File
@@ -47,6 +47,9 @@ error-log
# vercel
.vercel
# rust
rust-ai/target/
# python
browser-use-service/venv/
browser-use-service/__pycache__/
@@ -55,12 +58,3 @@ browser-use-service/.env
# typescript
*.tsbuildinfo
next-env.d.ts
.git
.git_bak
node_modules
target
# rust build artifacts
rust-ai/target/
# browser-use-service generated files
browser-use-service/*.txt
+124 -2
View File
@@ -6,11 +6,30 @@ 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 || "sam860/dolphin3-llama3.2:3b"
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
const DATABASE_URL = process.env.DATABASE_URL
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
@@ -77,7 +96,55 @@ function appendToImprovementLog(entry) {
}
// ── 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()
@@ -189,11 +256,47 @@ const server = http.createServer(async (req, res) => {
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 })
@@ -221,7 +324,26 @@ const server = http.createServer(async (req, res) => {
// POST /ai/chat
if (req.method === "POST" && pathname === "/ai/chat") {
const body = await parseBody(req)
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) {
+731 -103
View File
@@ -1,9 +1,21 @@
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging, tempfile
from datetime import datetime, timedelta
from fastapi import FastAPI, Query
from fastapi import FastAPI, Query, Body
from fastapi.middleware.cors import CORSMiddleware
import uvicorn
from playwright.async_api import async_playwright
from browser_use import Agent, Browser
from langchain_ollama import ChatOllama
def make_ollama(model: str | None = None, **kwargs) -> ChatOllama:
"""Create ChatOllama with required attrs for browser-use Agent compatibility."""
llm = ChatOllama(model=model or CLASSIFY_MODEL, **kwargs)
object.__setattr__(llm, 'provider', 'ollama')
object.__setattr__(llm, 'name', 'ChatOllama')
object.__setattr__(llm, 'model_name', llm.model)
return llm
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
logger = logging.getLogger(__name__)
@@ -20,7 +32,146 @@ PORT = int(os.getenv("PORT", "3008"))
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
FX_PROFILE = os.getenv('FX_PROFILE', '')
CHROME_PROFILE = os.getenv('CHROME_PROFILE', '')
CHROME_LAUNCH_ARGS = [
'--headless=new',
'--disable-blink-features=AutomationControlled',
'--disable-background-networking',
'--no-first-run',
'--disable-sync',
'--disable-field-trial-config',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-component-update',
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--metrics-recording-only',
'--no-default-browser-check',
'--disable-extensions-http-throttling',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
]
def detect_browser_from_profile(profile_path: str) -> str | None:
"""Detect browser type from profile path. Returns 'firefox', 'chromium', or None."""
if not profile_path:
return None
p = profile_path.lower().replace('\\', '/')
if 'firefox' in p or '.default' in p or '.dev-edition' in p:
return 'firefox'
if 'chrome' in p or 'chromium' in p or 'edge' in p:
return 'chromium'
return None
def copy_firefox_profile(src_path: str) -> str:
"""Copy essential Firefox profile files to a temp dir for Playwright."""
essential = ['cookies.sqlite', 'webappsstore.sqlite', 'permissions.sqlite']
dst = tempfile.mkdtemp(prefix='fb_fx_profile_')
for f_name in essential:
s = os.path.join(src_path, f_name)
if os.path.exists(s):
shutil.copy2(s, os.path.join(dst, f_name))
with open(os.path.join(dst, 'profiles.ini'), 'w') as fh:
fh.write("[Profile0]\nName=default\nIsRelative=0\nPath=.\nDefault=yes\n")
logger.info("Copied Firefox profile to %s", dst)
return dst
def copy_chrome_profile(user_data_dir: str, profile_dir: str = 'Default') -> str:
"""Copy Chrome profile to temp dir for launch_persistent_context."""
essential = ['Cookies', 'Login Data', 'Bookmarks', 'Web Data']
dst = tempfile.mkdtemp(prefix='fb_chrome_udir_')
src_profile = os.path.join(user_data_dir, profile_dir)
os.makedirs(dst, exist_ok=True)
os.makedirs(os.path.join(dst, profile_dir), exist_ok=True)
dst_profile = os.path.join(dst, profile_dir)
for f_name in essential:
s = os.path.join(src_profile, f_name)
if os.path.exists(s):
shutil.copy2(s, os.path.join(dst_profile, f_name))
for sub in ['Local Storage', 'Session Storage']:
s = os.path.join(src_profile, sub)
if os.path.isdir(s):
shutil.copytree(s, os.path.join(dst_profile, sub), dirs_exist_ok=True)
local_state_src = os.path.join(user_data_dir, 'Local State')
local_state_dst = os.path.join(dst, 'Local State')
if os.path.exists(local_state_src):
shutil.copy2(local_state_src, local_state_dst)
logger.info("Copied Chrome profile from %s to %s", src_profile, dst)
return dst
def ensure_ublock_extension() -> str | None:
"""Download and extract uBlock Origin extension for ad blocking."""
ext_dir = os.path.join(tempfile.gettempdir(), 'fb_ublock_origin')
manifest = os.path.join(ext_dir, 'manifest.json')
if os.path.exists(manifest):
return ext_dir
import urllib.request, zipfile
crx_url = ('https://clients2.google.com/service/update2/crx'
'?response=redirect&prodversion=133&acceptformat=crx3'
'&x=id%3Dddkjiahejlhfcafbddmgiahcphecmpfh%26uc')
crx_path = os.path.join(tempfile.gettempdir(), 'ublock_origin.crx')
try:
if not os.path.exists(crx_path):
logger.info("Downloading uBlock Origin extension...")
urllib.request.urlretrieve(crx_url, crx_path)
with zipfile.ZipFile(crx_path, 'r') as z:
z.extractall(ext_dir)
logger.info("uBlock Origin extension ready at %s", ext_dir)
return ext_dir
except Exception as e:
logger.warning("Failed to load uBlock Origin extension: %s", e)
return None
def extract_agent_posts(agent_result: str, page_text: str) -> list[dict]:
"""Parse posts from agent output or page text fallback."""
posts = []
# Try JSON from agent output
json_match = re.search(r'\[.*?\]', agent_result, re.DOTALL)
if json_match:
try:
parsed = json.loads(json_match.group())
if isinstance(parsed, list):
for item in parsed:
if isinstance(item, dict) and item.get('content'):
posts.append({
'content': item.get('content', ''),
'author': item.get('author', '') or item.get('publisher', '') or '',
'url': item.get('url', ''),
'date': item.get('date', ''),
})
except json.JSONDecodeError:
pass
if not posts:
lines = [l.strip() for l in agent_result.split('\n') if l.strip()]
cur = []
for l in lines:
if l.startswith(('1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.', '- ', '* ')):
if cur:
combined = ' '.join(cur)
if len(combined) > 30:
posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''})
cur = []
cur.append(l)
if cur:
combined = ' '.join(cur)
if len(combined) > 30:
posts.append({'content': combined[:500], 'author': '', 'url': '', 'date': ''})
return posts
BROAD_KEYWORDS = [
"website", "web design", "web develop", "web dev",
@@ -37,10 +188,166 @@ BROAD_KEYWORDS = [
"want someone", "need help with",
]
OFFER_PATTERNS = [
r"\bfree\s+domain\b",
r"\bweb\s+hosting\b",
r"\bfree\s+website\b",
r"\bprofessional\s+website",
r"\baffordable\s+web\b",
r"\bget\s+a\s+free\b",
r"\bsee\s+more\b",
r"\bbook\s+your\b",
r"\bhire\s+a\s+freelancer\b",
r"\bcontent\s+strategy\b",
r"\bdigital\s+marketing\b",
r"\bseo\s+service",
r"\bsocial\s+media\b",
r"\blimited\s+time\s+offer\b",
r"\bspecial\s+offer\b",
r"\bsign\s+up\s+now\b",
r"\br\d[\d,.]",
r"\bstarting\s+at\b",
r"\bper\s+month\b",
r"\bmonthly\b",
r"\bfreelancer\s+marketplace\b",
r"\bfirst\s+order\b",
r"\bdomain\s+name\b",
r"\bregister\s+your\s+domain\b",
r"\bsponsored\b",
r"\bpromoted\b",
r"\badvertisement\b",
]
REQUEST_PATTERNS = [
r"\bi\s+need\b",
r"\bi\s+need\s+someone\b",
r"\bi\s+need\s+a\b",
r"\bi\s+need\s+an\b",
r"\bi\s+want\b",
r"\bi\s+would\s+like\b",
r"\bi'm\s+looking\b",
r"\bi\s+am\s+looking\b",
r"\blooking\s+for\b",
r"\blooking\s+to\b",
r"\bwho\s+can\b",
r"\bcan\s+someone\b",
r"\bhelp\s+me\b",
r"\bneed\s+help\b",
r"\bwould\s+like\s+to\b",
r"\bwant\s+to\b",
r"\banyone\s+know\b",
r"\bdoes\s+anyone\b",
r"\brecommend\b",
r"\bsuggestion\b",
r"\bquote\s+(please|for|to)\b",
]
def firefox_init_script() -> str:
return r"""// Firefox Anti-Detection
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' });
Object.defineProperty(navigator, 'vendor', { get: () => '' });
Object.defineProperty(navigator, 'vendorSub', { get: () => '' });
Object.defineProperty(navigator, 'productSub', { get: () => '20100101' });
Object.defineProperty(navigator, 'product', { get: () => 'Gecko' });
Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' });
Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' });
Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' });
Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0' });
Object.defineProperty(navigator, 'buildID', { get: () => '20250101000000' });
if (window.chrome !== undefined) { window.chrome = undefined; }
if (navigator.permissions) {
const origQ = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r));
}
const _gEC = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(t, ...a) {
const ctx = _gEC.call(this, t, ...a);
if (ctx && (t === 'webgl' || t === 'webgl2')) {
const _gP = ctx.getParameter.bind(ctx);
ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); };
const _gE = ctx.getExtension.bind(ctx);
ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); };
}
return ctx;
};
const _tDU = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(...a) {
const img = _tDU.apply(this, a);
if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); }
return img;
};
const _gFD = AnalyserNode.prototype.getFloatFrequencyData;
if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
Object.defineProperty(navigator, 'plugins', { get: () => [{name:'Widevine Content Decryption Module',filename:'widevinecdm.dll',length:1,description:'Enables Widevine licenses'},{name:'OpenH264 Video Codec',filename:'openh264.dll',length:1,description:'H.264 video codec'}] });
Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });"""
def chromium_init_script() -> str:
return r"""// Enhanced Chromium Anti-Detection
Object.defineProperty(navigator, 'webdriver', { get: () => false });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
Object.defineProperty(navigator, 'maxTouchPoints', { get: () => 0 });
Object.defineProperty(navigator, 'oscpu', { get: () => 'Windows NT 10.0; Win64; x64' });
Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' });
Object.defineProperty(navigator, 'vendorSub', { get: () => '' });
Object.defineProperty(navigator, 'productSub', { get: () => '20030107' });
Object.defineProperty(navigator, 'product', { get: () => 'Gecko' });
Object.defineProperty(navigator, 'appCodeName', { get: () => 'Mozilla' });
Object.defineProperty(navigator, 'appName', { get: () => 'Netscape' });
Object.defineProperty(navigator, 'appVersion', { get: () => '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' });
Object.defineProperty(navigator, 'userAgent', { get: () => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' });
window.chrome = { runtime: { onConnect: { addListener: () => {} }, onMessage: { addListener: () => {} }, sendMessage: () => {} }, app: { isInstalled: false, InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, getDetails: () => null, getIsInstalled: () => false }, csi: () => {}, loadTimes: () => {} };
if (navigator.permissions) {
const origQ = navigator.permissions.query.bind(navigator.permissions);
navigator.permissions.query = (p) => origQ(p).then(r => (['camera','microphone','geolocation','notifications'].includes(p.name) ? Object.assign(r, { state: 'prompt' }) : r));
}
const _gEC = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(t, ...a) {
const ctx = _gEC.call(this, t, ...a);
if (ctx && (t === 'webgl' || t === 'webgl2')) {
const _gP = ctx.getParameter.bind(ctx);
ctx.getParameter = function(p) { if (p===37445) return 'Intel Inc.'; if (p===37446) return 'Intel Iris OpenGL Engine'; if (p===7936||p===7937||p===35724) return ''; return _gP(p); };
const _gE = ctx.getExtension.bind(ctx);
ctx.getExtension = function(n) { if (n==='WEBGL_debug_renderer_info') return null; return _gE(n); };
}
return ctx;
};
const _tDU = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(...a) {
const img = _tDU.apply(this, a);
if (img.includes('image/png') && img.length > 5000) { const n = btoa(String.fromCharCode(Math.floor(Math.random()*256))); return img.slice(0,-100)+n+img.slice(-100); }
return img;
};
const _gFD = AnalyserNode.prototype.getFloatFrequencyData;
if (_gFD) { AnalyserNode.prototype.getFloatFrequencyData = function(a) { _gFD.call(this,a); for(let i=0;i<a.length;i++) a[i]+=(Math.random()-0.5)*0.02; }; }
Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });
Object.defineProperty(navigator, 'plugins', { get: () => [1,2,3,4,5].map(i => ({name:['Chrome PDF Plugin','Chrome PDF Viewer','Native Client','Widevine Content Decryption Module','Chromium PDF Viewer'][i-1],filename:'internal-pdf-viewer',length:1,description:['Portable Document Format','','Native Client Executable','Enables Widevine licenses',''][i-1]})) });
Object.defineProperty(navigator, 'mimeTypes', { get: () => [{type:'application/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]},{type:'text/pdf',suffixes:'pdf',description:'Portable Document Format',enabledPlugin:navigator.plugins[0]}] });"""
def kw_match(text: str) -> bool:
t = text.lower()
return any(kw in t for kw in BROAD_KEYWORDS)
def is_request(text: str) -> bool:
t = text.lower()
return any(re.search(p, t) for p in REQUEST_PATTERNS)
def is_offer(text: str) -> bool:
t = text.lower()
return any(re.search(p, t) for p in OFFER_PATTERNS)
FB_SEARCHES = [
"looking for web developer",
"need a website designed",
@@ -50,10 +357,19 @@ FB_SEARCHES = [
"looking for someone to create website",
"need ecommerce website built",
"want to hire web developer",
"website quote please",
"need wordpress website",
"I need a website for my business",
"need a site for my business",
"looking for website designer South Africa",
"need website for my small business",
"who can build me a website",
"want to create a website for my business",
"looking for affordable web design",
"need a web developer South Africa",
"help me build a website",
"need someone to design my website",
"looking for web designer near me",
"need a website for my startup",
]
VIEWPORTS = [
@@ -136,52 +452,121 @@ def _parse_fb_date(block: list[str]) -> str:
pass
return datetime.now().strftime('%Y-%m-%d')
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
lines = [l.strip() for l in raw.split('\n')]
blocks = []
cur = []
fb_run = 0
for l in lines:
if 'facebook' in l.lower():
fb_run += 1
if cur and fb_run >= 2:
blocks.append(cur)
cur = []
else:
fb_run = 0
if l and len(l) >= 15:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) >= 2:
cur.append(l)
if cur:
blocks.append(cur)
def _is_within_days(date_str: str, max_days: int = 3) -> bool:
"""Check if date is within max_days from now. Empty/unparseable = keep."""
if not date_str:
return True
try:
dt = datetime.strptime(date_str.strip(), '%Y-%m-%d')
return (datetime.now() - dt).days <= max_days
except ValueError:
return True
def _clean_fb_text(text: str) -> str:
cleaned_lines = []
for l in text.split('\n'):
stripped = l.strip()
if not stripped:
continue
if len(stripped) < 3:
continue
if all(not c.isalpha() for c in stripped):
continue
cleaned_lines.append(stripped)
return '\n'.join(cleaned_lines)
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
posts = []
seen_texts = set()
for block in blocks:
if not block:
for el in elements:
raw_text = el.get('text', '')
if len(raw_text) < 40:
continue
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
if not kw_indices:
text = _clean_fb_text(raw_text)
if len(text) < 40:
continue
i = kw_indices[0]
start = max(0, i - 2)
end = min(len(block), i + 5)
snippet = ' '.join(block[start:end])
if len(snippet) < 40:
if is_offer(text):
continue
dekey = snippet[:80]
lines = text.split('\n')
dekey = text[:80]
if dekey in seen_texts:
continue
seen_texts.add(dekey)
request_score = 2 if is_request(text) else 0
post_url = el.get('url') or base_url
# Prefer JS-extracted date, fall back to text parsing
js_date = (el.get('date') or '').strip()
if js_date:
# Try ISO datetime from <time datetime>
try:
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
date_str = dt.strftime('%Y-%m-%d')
except (ValueError, TypeError):
date_str = _parse_fb_date([js_date])
else:
date_str = _parse_fb_date(lines)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": block[start] if start < i else '',
"url": url,
"title": text[:300],
"content": text[:1000],
"author": el.get('author', ''),
"url": post_url,
"source": "facebook",
"date": _parse_fb_date(block),
"date": date_str,
"_score": request_score,
})
posts.sort(key=lambda p: p.get('_score', 0), reverse=True)
for p in posts:
p.pop('_score', None)
return posts
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
lines = [l.strip() for l in raw.split('\n') if l.strip() and len(l.strip()) >= 15]
posts = []
seen_texts = set()
cur = []
for l in lines:
words = re.findall(r'[A-Za-z]{2,}', l)
if len(words) < 2:
continue
lower = l.lower()
if any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(l):
if cur:
snippet = ' '.join(cur[-3:] + [l])
if len(snippet) >= 40 and not is_offer(snippet):
dekey = snippet[:80]
if dekey not in seen_texts:
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": '',
"url": url,
"source": "facebook",
"date": _parse_fb_date(cur + [l]),
})
cur = []
else:
if not is_offer(l):
snippet = l
if len(snippet) >= 40:
dekey = snippet[:80]
if dekey not in seen_texts:
seen_texts.add(dekey)
posts.append({
"title": snippet[:300],
"content": snippet[:1000],
"author": '',
"url": url,
"source": "facebook",
"date": _parse_fb_date([l]),
})
cur.append(l)
if len(cur) > 10:
cur.pop(0)
return posts
async def human_scroll(page, steps: int = None, total_delay: float = None):
@@ -191,6 +576,13 @@ async def human_scroll(page, steps: int = None, total_delay: float = None):
for i in range(steps):
scroll_dist = random.randint(200, 600)
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
try:
vp = await page.evaluate('({w: window.innerWidth, h: window.innerHeight})')
x = random.randint(100, vp['w'] - 100)
y = random.randint(100, vp['h'] - 100)
await page.mouse.move(x, y, steps=random.randint(15, 35))
except Exception:
pass
await page.wait_for_timeout(int(step_delay * 1000))
if random.random() < 0.3:
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
@@ -212,15 +604,95 @@ async def random_idle(page):
except Exception:
pass
async def search_facebook(page, query: str) -> list[dict]:
async def _get_article_elements(page) -> list[dict]:
return await page.evaluate('''() => {
const results = [];
const seenTexts = new Set();
const selectors = [
'div[role="article"]',
'div[role="feed"] > div',
'div.x1yztbdb',
'div[data-pagelet]',
];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
for (const el of els) {
const text = (el.innerText || '').trim();
if (text.length < 40) continue;
const key = text.substring(0, 80);
if (seenTexts.has(key)) continue;
seenTexts.add(key);
// --- Publisher name ---
let author = '';
const nameEl = el.querySelector('h4, strong, a[role="link"]');
if (nameEl) {
author = (nameEl.innerText || '').trim();
}
if (!author) {
const links = el.querySelectorAll('a');
for (const a of links) {
const t = (a.innerText || '').trim();
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
author = t;
break;
}
}
}
// --- Post URL ---
let postUrl = '';
for (const a of el.querySelectorAll('a')) {
const h = (a.getAttribute('href') || '');
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
break;
}
}
// --- Date from <time datetime> ---
let date = '';
const timeEl = el.querySelector('time');
if (timeEl) {
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
}
results.push({ text, author, url: postUrl, date });
}
if (results.length > 0) break;
}
return results;
}''')
async def _ensure_page(page, context):
try:
await page.evaluate('1')
return page
except Exception:
logger.warning("Page was closed mid-scrape, creating a fresh page")
page = await context.new_page()
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed during page recreation")
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
return page
async def search_facebook(page, context, query: str):
page = await _ensure_page(page, context)
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
try:
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
try:
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
except Exception:
pass
await page.wait_for_timeout(random.randint(3000, 8000))
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
# Accidental overscroll: 10% chance to jump to bottom and back
if random.random() < 0.1:
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await page.wait_for_timeout(random.randint(1000, 3000))
@@ -234,11 +706,34 @@ async def search_facebook(page, query: str) -> list[dict]:
if random.random() < 0.3:
await random_idle(page)
raw = await page.evaluate('document.body.innerText')
raw_articles = await _get_article_elements(page)
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
if not posts:
raw = await page.evaluate('document.body.innerText')
posts = _extract_posts_from_text(raw, url)
except Exception as e:
logger.warning("Facebook search failed: %s", e)
return []
return _extract_posts_from_text(raw, url)
logger.warning("Facebook search '%s' failed: %s", query, e)
return page, []
return page, posts
DETECTION_SIGNALS = [
'/checkpoint/', '/login.php?', 'action=security_check',
'unusual activity', 'suspicious login', 'suspicious activity',
'temporarily blocked', 'temporarily restricted',
'confirm your identity', 'enter the code we sent',
'we noticed unusual', 'help us confirm',
'security check', 'challenge', 'please verify',
]
def check_detection_signals(page_url: str, page_text: str = '') -> str | None:
"""Check page for Facebook detection signals. Returns signal text or None."""
combined = (page_url + ' ' + page_text).lower()
for signal in DETECTION_SIGNALS:
if signal in combined:
return signal
return None
def cleanup_chrome():
import subprocess, signal
@@ -248,89 +743,110 @@ def cleanup_chrome():
pass
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
cleanup_chrome()
fb_cookies = await get_fb_cookies(profile_path)
if not fb_cookies:
logger.warning("No Facebook cookies available")
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
"""Dispatcher — Firefox primary, browser-use Agent fallback."""
effective_path = profile_path or FX_PROFILE
browser_type = detect_browser_from_profile(effective_path)
if not browser_type and CHROME_PROFILE:
browser_type = detect_browser_from_profile(CHROME_PROFILE)
effective_path = CHROME_PROFILE
logger.info("Detected browser: %s (profile: %s)", browser_type or "none", effective_path)
# Firefox primary (raw Playwright, stealth)
if browser_type == "firefox":
result = await _scrape_with_firefox(effective_path, force)
if result.get("success") or not result.get("flagged"):
return result
logger.warning("Firefox path failed (%s), falling back to Agent", result.get("flag_reason", "unknown"))
return await _scrape_with_agent(force)
# CHROME_PROFILE set or no profile → Agent
if browser_type == "chromium":
return await _scrape_with_agent(force)
# No profile at all → try Agent (fresh Chromium)
return await _scrape_with_agent(force)
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
if not profile_path:
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
profile_dir = None
try:
profile_dir = copy_firefox_profile(profile_path)
async with async_playwright() as pw:
browser = await pw.chromium.launch(
context = await pw.firefox.launch_persistent_context(
user_data_dir=profile_dir,
headless=True,
args=['--disable-blink-features=AutomationControlled']
)
viewport = random.choice(VIEWPORTS)
context = await browser.new_context(
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
viewport=viewport,
firefox_user_prefs={
"dom.webdriver.enabled": False,
"dom.webdriver.timeout": 0,
"useAutomationExtension": False,
"privacy.trackingprotection.enabled": False,
"privacy.trackingprotection.fingerprinting.enabled": False,
"privacy.trackingprotection.cryptomining.enabled": False,
},
)
pages = context.pages
page = pages[0] if pages else await context.new_page()
await context.add_init_script(firefox_init_script())
await context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => false });
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
window.chrome = { runtime: {} };
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
""")
for c in fb_cookies:
try:
await context.add_cookies([c])
except Exception as e:
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
try:
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception:
logger.warning("Google navigation failed, trying Facebook directly")
page = await context.new_page()
# Navigate through google first for a legitimate Referer header
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
await page.wait_for_timeout(random.randint(1000, 3000))
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
await page.wait_for_timeout(random.randint(3000, 8000))
url = page.url
if '/login' in url.lower():
logger.warning("Facebook login page detected — account flagged")
await browser.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
page_text = await page.evaluate('document.body.innerText') if '/login' in url.lower() else ''
det = check_detection_signals(url, page_text)
if det or '/login' in url.lower():
logger.warning("Facebook login page detected — flag: %s", det or "login_page")
await context.close()
return {"success": False, "leads": [], "flagged": True, "flag_reason": det or "login_page", "error": "Facebook login page detected"}
# Browse feed like a human before searching
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
if random.random() < 0.25:
await page.evaluate("window.scrollTo(0, 0)")
await page.wait_for_timeout(random.randint(2000, 5000))
await human_scroll(page, steps=random.randint(1, 2))
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
if not force and random.random() < 0.3:
await page.wait_for_timeout(random.randint(8000, 20000))
await browser.close()
await context.close()
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
all_posts = []
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
for i, query in enumerate(searches):
try:
posts = await search_facebook(page, query)
all_posts.extend(posts)
# Between searches: random break with possible small scroll
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
# Tab switch: 15% chance after 2nd-3rd search
if i == random.randint(1, 2) and random.random() < 0.15:
new_page = await context.new_page()
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
page, posts = await search_facebook(page, context, query)
all_posts.extend(posts)
if not posts:
continue
if random.random() < 0.4:
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
delay = random.uniform(8, 25)
await page.wait_for_timeout(int(delay * 1000))
if i == random.randint(0, 1) and random.random() < 0.15:
new_page = await context.new_page()
try:
await new_page.goto('https://www.facebook.com/groups/', wait_until='domcontentloaded', timeout=15000)
await new_page.wait_for_timeout(random.randint(3000, 8000))
await new_page.close()
await page.wait_for_timeout(random.randint(1000, 3000))
except Exception as e:
logger.warning("Facebook search '%s' failed: %s", query, e)
except Exception:
pass
await new_page.close()
page = await _ensure_page(page, context)
# Idle before closing — human would pause
if random.random() < 0.5:
await page.wait_for_timeout(random.randint(3000, 10000))
await browser.close()
await context.close()
seen = set()
deduped = []
@@ -340,14 +856,102 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("Facebook scrape failed: %s", e)
logger.error("Firefox scrape failed: %s", e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.exists(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception:
pass
async def _scrape_with_agent(force: bool = False) -> dict:
"""Fallback scraper — browser-use Agent + ChatOllama (free/local, Chromium)."""
cleanup_chrome()
profile_dir = None
try:
user_data_dir = None
if CHROME_PROFILE:
profile_dir = copy_chrome_profile(CHROME_PROFILE)
user_data_dir = profile_dir
browser = Browser(
headless=True,
args=CHROME_LAUNCH_ARGS,
user_data_dir=user_data_dir,
allowed_domains=["*.facebook.com", "*.messenger.com", "www.google.com"],
)
await browser.start()
all_posts = []
for query in random.sample(FB_SEARCHES, k=random.randint(2, 4)):
agent = Agent(
task=f"""You are logged into Facebook. Do the following:
1. Navigate to facebook.com and make sure you are on the homepage
2. Use the Facebook search to find: {query}
3. Scroll through the results
4. For each relevant post, extract and list:
- The publisher/author name (who posted it)
- The post text content
- The post URL (if visible)
- The post date
5. ONLY include posts from the last 3 days
6. Collect as many posts as you can (aim for 5-10 per search)
When done, return the data as a JSON list with keys: content, author, url, date.""",
llm=make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
max_actions_per_step=3,
use_vision=False,
max_failures=6,
step_timeout=180,
)
history = await agent.run()
result = history.final_result() or ""
posts = extract_agent_posts(result, "")
all_posts.extend(posts)
if random.random() < 0.3:
await asyncio.sleep(random.randint(5, 15))
await browser.close()
seen = set()
deduped = []
for p in all_posts:
key = p.get('content', '')[:100]
if key not in seen:
seen.add(key)
deduped.append(p)
# Filter to last 3 days only
deduped = [p for p in deduped if _is_within_days(p.get('date', ''), 3)]
leads = deduped[:20]
if leads:
leads = await classify_leads(leads)
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
except Exception as e:
logger.error("Agent scrape failed: %s", e)
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
finally:
if profile_dir and os.path.isdir(profile_dir):
try:
shutil.rmtree(profile_dir, ignore_errors=True)
except Exception as e:
logger.warning("Failed to clean up Chrome profile %s: %s", profile_dir, e)
async def ask_ollama(prompt: str) -> str:
import httpx
@@ -417,16 +1021,18 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
"need a website", "my website", "business website",
"need a designer", "help with my website",
"redesign", "update my website",
"looking for", "need a", "need an", "looking to",
"need someone", "hire a", "want someone",
"need help with", "would like", "build me",
"design my", "make me a", "create my",
]
filtered = []
for r in results:
t = r['title'].lower()
if not any(kw in t for kw in strict_keywords):
continue
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
'affordable web', 'web hosting', 'free website',
'limited time', 'special offer', 'sign up now',
'offer']):
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
'affordable web design package', 'web hosting']):
continue
filtered.append(r)
return filtered[:10]
@@ -436,6 +1042,28 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
async def health():
return {"status": "ok"}
@app.post("/agent/run")
async def agent_run(task: str = Body(..., embed=True)):
"""Run a browser-use Agent with ChatOllama (free/local)."""
try:
browser = Browser(headless=True, args=CHROME_LAUNCH_ARGS)
await browser.start()
agent = Agent(
task=task,
llm=make_ollama(num_ctx=32000, temperature=0.3),
browser=browser,
use_vision=False,
max_actions_per_step=5,
max_failures=8,
step_timeout=180,
)
history = await agent.run()
await browser.close()
return {"success": True, "result": history.final_result()}
except Exception as e:
logger.error("Agent run failed: %s", e)
return {"success": False, "error": str(e)}
@app.post("/scrape/facebook")
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
result = await scrape_facebook(profile_path, force)
+22
View File
@@ -0,0 +1,22 @@
create table if not exists contacts (
id uuid default gen_random_uuid() primary key,
user_id uuid references auth.users(id) on delete cascade,
name text not null,
phone text not null,
avatar_url text,
created_at timestamp default now()
);
alter table contacts enable row level security;
create policy "Users see own contacts"
on contacts for select
using (auth.uid() = user_id);
create policy "Users insert own contacts"
on contacts for insert
with check (auth.uid() = user_id);
create policy "Users delete own contacts"
on contacts for delete
using (auth.uid() = user_id);
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS invites (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
token VARCHAR(64) NOT NULL UNIQUE,
phone VARCHAR(50),
created_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
);
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
@@ -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.
-- ============================================================================
+145
View File
@@ -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;
+6
View File
@@ -34,5 +34,11 @@ BEGIN;
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
\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 ==='
COMMIT;
+25
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -1,4 +1,12 @@
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 = {
eslint: {
@@ -12,6 +20,7 @@ const nextConfig: NextConfig = {
},
],
},
}
export default nextConfig
+16
View File
@@ -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,
},
},
}
+586 -281
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -4,12 +4,14 @@
"private": true,
"scripts": {
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
"dev:signaling": "node signaling-server.mjs",
"dev: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:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "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",
"start": "next start -p 3006",
"lint": "eslint"
@@ -32,6 +34,8 @@
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.8",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.108.2",
"@tanstack/react-table": "^8.20.6",
"autoprefixer": "^10.4.20",
"bcryptjs": "^3.0.3",
@@ -47,6 +51,8 @@
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.0",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"vaul": "^1.1.2",
+1 -1
View File
@@ -446,7 +446,7 @@ async fn main() {
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
+125
View File
@@ -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
}
+221
View File
@@ -0,0 +1,221 @@
import { createServer } from "http"
import { Server } from "socket.io"
import { SignJWT, jwtVerify } from "jose"
import pg from "pg"
const PORT = process.env.SIGNALING_PORT || 3007
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
const pool = new pg.Pool({ connectionString: DATABASE_URL })
const httpServer = createServer()
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
const onlineUsers = new Map()
const rooms = new Map()
io.use((socket, next) => {
const token = socket.handshake.auth?.token
if (token) {
jwtVerify(token, JWT_SECRET)
.then(({ payload }) => {
socket.data.userId = payload.userId
socket.data.role = payload.role
next()
})
.catch(() => {
socket.data.userId = null
socket.data.role = null
next()
})
} else {
socket.data.userId = null
socket.data.role = null
next()
}
})
io.on("connection", (socket) => {
const userId = socket.data.userId
if (userId) {
onlineUsers.set(userId, socket.id)
socket.broadcast.emit("user:online", { userId })
}
if (userId) {
socket.on("user:lookup", async ({ phone }, callback) => {
try {
const result = await pool.query(
`SELECT id, username, first_name, last_name, phone, avatar_url
FROM users
WHERE phone = $1 AND deleted_at IS NULL
LIMIT 1`,
[phone],
)
if (result.rows.length > 0) {
const user = result.rows[0]
callback({
found: true,
user: {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
avatar: user.avatar_url,
online: onlineUsers.has(user.id),
},
})
} else {
callback({ found: false })
}
} catch {
callback({ found: false, error: "Lookup failed" })
}
})
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
let info = callerInfo
if (!info) {
try {
const result = await pool.query(
`SELECT id, username, first_name, last_name, avatar_url
FROM users WHERE id = $1`,
[userId],
)
if (result.rows.length > 0) {
const u = result.rows[0]
info = {
id: u.id,
username: u.username,
firstName: u.first_name,
lastName: u.last_name,
avatar: u.avatar_url,
}
}
} catch {}
}
io.to(targetSocketId).emit("call:incoming", {
from: userId,
sdp,
callerInfo: info,
})
} else {
socket.emit("call:user-offline", { userId: to })
}
})
socket.on("call:answer", ({ to, sdp }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
}
})
socket.on("call:ice-candidate", ({ to, candidate }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
}
})
socket.on("call:end", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:ended", { from: userId })
}
})
socket.on("call:reject", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:rejected", { from: userId })
}
})
socket.on("call:busy", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
io.to(targetSocketId).emit("call:busy", { from: userId })
}
})
}
socket.on("room:join", ({ roomId, displayName }) => {
const participantId = userId || `anon-${socket.id}`
const participantLabel = displayName || participantId
if (!rooms.has(roomId)) {
rooms.set(roomId, [])
}
const participants = rooms.get(roomId)
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
socket.join(roomId)
const list = participants.map(p => ({ id: p.id, label: p.label }))
if (participants.length === 1) {
socket.emit("room:waiting", { roomId, participants: list })
} else if (participants.length === 2) {
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
} else {
socket.emit("room:participant-joined", { roomId, participants: list })
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
}
})
socket.on("room:offer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:offer", { sdp })
})
socket.on("room:answer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:answer", { sdp })
})
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
socket.to(roomId).emit("room:ice-candidate", { candidate })
})
socket.on("room:leave", ({ roomId }) => {
socket.leave(roomId)
const participants = rooms.get(roomId)
if (participants) {
const idx = participants.findIndex(p => p.socketId === socket.id)
if (idx !== -1) participants.splice(idx, 1)
if (participants.length === 0) {
rooms.delete(roomId)
} else {
const list = participants.map(p => ({ id: p.id, label: p.label }))
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
}
}
})
socket.on("disconnect", () => {
for (const [roomId, participants] of rooms) {
const idx = participants.findIndex(p => p.socketId === socket.id)
if (idx !== -1) {
participants.splice(idx, 1)
if (participants.length === 0) {
rooms.delete(roomId)
} else {
const list = participants.map(p => ({ id: p.id, label: p.label }))
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
}
}
}
if (userId) {
onlineUsers.delete(userId)
socket.broadcast.emit("user:offline", { userId })
}
})
})
httpServer.listen(PORT, () => {
console.log(`[signaling] server running on port ${PORT}`)
})
+453
View File
@@ -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>
+6 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download,
} from "lucide-react"
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
import { useTheme } from "next-themes"
import { useUser } from "@/providers/user-provider"
import { toast } from "sonner"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react"
@@ -124,6 +125,7 @@ export default function ChatsPage() {
const [searchResults, setSearchResults] = useState<any[]>([])
const [searchingUsers, setSearchingUsers] = useState(false)
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -642,12 +644,9 @@ export default function ChatsPage() {
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
@@ -995,6 +994,8 @@ export default function ChatsPage() {
</ScrollArea>
</DialogContent>
</Dialog>
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
</div>
)
}
+1 -1
View File
@@ -65,7 +65,7 @@ export default function DashboardPage() {
<div className="space-y-6 relative">
{/* 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-[#CC0000] dark:text-[#FF1111] tracking-wider">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
DAILY BUGLE
</span>
</div>
+11 -12
View File
@@ -1,14 +1,12 @@
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
if (!["sales", "admin", "super_admin"].includes(user.role)) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { message } = await request.json()
@@ -16,15 +14,16 @@ export async function POST(request: NextRequest) {
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")?.value
if (!sessionCookie) return NextResponse.json({ error: "No session" }, { status: 401 })
const response = await chatWithAI(message, sessionCookie)
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
return NextResponse.json({ error: "No session token" }, { status: 401 })
}
const response = await chatWithAI(message, jwtToken)
return NextResponse.json({ response })
} catch (error) {
} catch (error: any) {
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 })
}
}
+11
View File
@@ -0,0 +1,11 @@
import { NextResponse } from "next/server"
import { cookies } from "next/headers"
export async function GET() {
const cookieStore = await cookies()
const token = cookieStore.get("session")?.value
if (!token) {
return NextResponse.json({ error: "No session" }, { status: 401 })
}
return NextResponse.json({ token })
}
+2
View File
@@ -9,6 +9,7 @@ import {
resetFailedAttempts,
isAccountLocked,
createSession,
setSessionContext,
} from "@/lib/auth"
export async function POST(request: NextRequest) {
@@ -106,6 +107,7 @@ export async function POST(request: NextRequest) {
)
await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
+64
View File
@@ -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 })
}
}
+69
View File
@@ -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 })
}
}
+118
View File
@@ -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 })
}
}
+8 -5
View File
@@ -13,10 +13,11 @@ export async function GET() {
c.id,
c.updated_at,
cp_me.last_read_at,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
u.avatar_url AS other_user_avatar_url,
u.id AS other_user_id,
u.first_name || ' ' || u.last_name AS other_user_name,
u.email AS other_user_email,
u.phone AS other_user_phone,
u.avatar_url AS other_user_avatar_url,
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
@@ -39,6 +40,7 @@ export async function GET() {
id: row.other_user_id,
name: row.other_user_name,
email: row.other_user_email,
phone: row.other_user_phone || "",
avatar: avatarSvgUrl(row.other_user_name),
},
lastMessage: row.last_message || "",
@@ -90,7 +92,7 @@ export async function POST(request: NextRequest) {
)
const otherUser = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
FROM users WHERE id = $1`,
[userId],
)
@@ -105,6 +107,7 @@ export async function POST(request: NextRequest) {
id: other.id,
name: other.name,
email: other.email,
phone: other.phone || "",
avatar: avatarSvgUrl(other.name),
},
lastMessage: "",
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import crypto from "crypto"
export async function POST(request: NextRequest) {
try {
const { phone, token: clientToken } = await request.json()
if (!phone) {
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
}
const token = clientToken || crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
[token, phone],
)
const origin = request.headers.get("origin") || "http://localhost:3000"
const inviteUrl = `${origin}/join/${token}`
return NextResponse.json({ token, inviteUrl })
} catch {
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
}
}
@@ -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 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
export async function GET(request: NextRequest) {
const phone = request.nextUrl.searchParams.get("phone")
if (!phone) {
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
}
try {
const result = await query(
`SELECT id, username, first_name, last_name, phone, avatar_url
FROM users
WHERE phone = $1 AND deleted_at IS NULL
LIMIT 1`,
[phone],
)
if (result.rows.length === 0) {
return NextResponse.json({ found: false })
}
const user = result.rows[0]
return NextResponse.json({
found: true,
user: {
id: user.id,
username: user.username,
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
avatar: user.avatar_url,
},
})
} catch {
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
}
}
+7 -4
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser } from "@/lib/auth"
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
export async function GET(request: NextRequest) {
@@ -63,17 +63,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
await setSessionContext(sessionUser.id)
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
const username = email.split("@")[0]
const passwordHash = await hashPassword(password)
const passwordEncrypted = await encryptPassword(password)
const result = await query(
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`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, $8)
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 = (
+226
View File
@@ -0,0 +1,226 @@
"use client"
import { useState, useEffect, use, useCallback } from "react"
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
const { roomId } = use(params)
const {
callState,
isMuted,
isSpeakerOn,
callDuration,
error,
isReady,
participants,
joinRoom,
endCall,
toggleMute,
toggleSpeaker,
formatDuration,
} = useWebRTCCall({ anonymous: true })
const [displayName, setDisplayName] = useState("")
const [joined, setJoined] = useState(false)
const [micError, setMicError] = useState<string | null>(null)
const [connectionTimeout, setConnectionTimeout] = useState(false)
useEffect(() => {
if (isReady) return
const timer = setTimeout(() => {
if (!isReady) setConnectionTimeout(true)
}, 10000)
return () => clearTimeout(timer)
}, [isReady])
const handleJoin = useCallback(async () => {
if (!displayName.trim()) return
setMicError(null)
try {
await navigator.mediaDevices.getUserMedia({ audio: true })
joinRoom(roomId, displayName.trim() || "Anonymous")
setJoined(true)
} catch {
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
}
}, [displayName, roomId, joinRoom])
const handleEnd = useCallback(() => {
endCall()
}, [endCall])
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
</>
) : !isReady ? (
<>
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
<p className="text-[#888888] text-sm">Connecting to call...</p>
</div>
</>
) : (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
{micError && (
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
)}
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
placeholder="Enter your name"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
<button
onClick={handleJoin}
disabled={!displayName.trim()}
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Join Call
</button>
</>
)}
</div>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
{callState === "calling" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "waiting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
{participants.length > 0 && (
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "participant_joined" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "connecting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{callState === "connected" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="text-[#CC0000] font-mono text-lg mb-6">
{formatDuration(callDuration)}
</div>
<div className="flex justify-center gap-4">
<button
onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
>
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button>
<button
onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
>
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
<button
onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
>
<PhoneOff className="h-5 w-5" />
</button>
</div>
</div>
)}
{(callState === "ended" || callState === "idle") && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
{callDuration > 0 && (
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
)}
</div>
)}
{error && (
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
)}
</div>
</div>
)
}
+318 -8
View File
@@ -1,4 +1,5 @@
@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 components;
@@ -28,16 +29,17 @@
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 0 100% 40%;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 0 100% 40%;
--ring: 221.2 83.2% 53.3%;
--sidebar: 216 12% 92%;
--sidebar-foreground: 0 0% 20%;
--sidebar-primary: 0 91.2% 59.8%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96.1%;
--sidebar-accent-foreground: 222.2 84% 4.9%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 0 100% 40%;
--sidebar-accent: 218 16% 87%;
--sidebar-accent-foreground: 0 0% 20%;
--sidebar-border: 220 11% 84%;
--sidebar-ring: 0 76.3% 48%;
--radius: 0.5rem;
--theme-primary: hsl(var(--primary));
}
.dark {
@@ -322,6 +324,314 @@
--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 {
* {
@apply border-border;
+69
View File
@@ -0,0 +1,69 @@
import { query } from "@/lib/db"
import { redirect } from "next/navigation"
interface Props {
params: Promise<{ token: string }>
}
function ErrorPage({ message }: { message: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
{message}
</h1>
</div>
</div>
)
}
export default async function JoinPage({ params }: Props) {
const { token } = await params
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (UUID_REGEX.test(token)) {
redirect(`/call/${token}`)
}
const result = await query(
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
[token],
)
if (result.rows.length === 0) {
return <ErrorPage message="This call link is invalid." />
}
const invite = result.rows[0]
if (new Date(invite.expires_at) <= new Date()) {
return <ErrorPage message="This call has expired." />
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
You&apos;re Invited!
</h1>
<p className="text-[#888888] text-sm mb-6">
Someone wants to connect with you on our platform.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
</div>
<p className="text-xs text-[#888888] mb-6">
Create an account to start making free voice calls to this person and others on the platform.
</p>
<a
href="/register"
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
>
Create Account
</a>
</div>
</div>
)
}
+5 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next"
import { Inter } from "next/font/google"
import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
@@ -31,8 +32,10 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
{children}
<Toaster />
<WebsiteThemeProvider>
{children}
<Toaster />
</WebsiteThemeProvider>
</ThemeProvider>
</body>
</html>
+9 -14
View File
@@ -1,8 +1,7 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useRouter } from "next/navigation"
import { COMPANY_NAME } from "@/lib/constants"
import { useSearchParams } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react"
const waves = [
@@ -14,7 +13,8 @@ const waves = [
]
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/dashboard"
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
@@ -28,11 +28,11 @@ export default function LoginPage() {
const testimonials = [
{
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.",
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) {
router.push("/dashboard")
window.location.href = redirectTo
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.")
@@ -230,16 +230,11 @@ export default function LoginPage() {
return (
<div className="flex min-h-screen bg-[#0a0a0f]">
<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="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]">
Your Agency&apos;s{" "}
<span className="growth-word">Growth</span>{" "}
+111 -33
View File
@@ -1,7 +1,7 @@
"use client"
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) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
@@ -24,9 +24,26 @@ export function AIChat() {
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
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 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(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -56,29 +73,13 @@ export function AIChat() {
},
])
})
checkServer()
}, [])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [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 msg = input.trim()
if (!msg || loading) return
@@ -96,8 +97,8 @@ export function AIChat() {
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to get response")
const data = await res.json().catch(() => ({}))
throw new Error(data.error || `Error ${res.status}`)
}
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 (
<div className="flex flex-col h-full">
{ollamaStatus === false && (
<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>
)}
{bootState === "ready" && readyOverlay}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => (
@@ -163,7 +233,10 @@ export function AIChat() {
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
<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>
)}
@@ -192,10 +265,15 @@ export function AIChat() {
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"
>
{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>
</div>
</div>
</div>
)
}
}
+350
View File
@@ -0,0 +1,350 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { Phone, X, Copy, MessageSquare } from "lucide-react"
import { getSupabase } from "@/lib/supabase"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
interface Contact {
id: string
name: string
phone: string
avatar_url: string | null
}
interface VoiceCallModalProps {
open: boolean
onClose: () => void
}
function formatPhoneForWhatsApp(phone: string): string {
const digits = phone.replace(/[^0-9]/g, "")
if (!digits) return ""
if (digits.startsWith("27")) return digits
if (digits.startsWith("0")) return "27" + digits.slice(1)
return digits
}
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
const [contacts, setContacts] = useState<Contact[]>([])
const [contactsLoading, setContactsLoading] = useState(false)
const [contactsError, setContactsError] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
const [phoneNumber, setPhoneNumber] = useState("")
const [phoneError, setPhoneError] = useState(false)
const [callLink, setCallLink] = useState<string | null>(null)
const [shareSent, setShareSent] = useState(false)
const [shareError, setShareError] = useState<string | null>(null)
const [targetPhone, setTargetPhone] = useState<string>("")
const [showWaUrl, setShowWaUrl] = useState<string | null>(null)
const { createRoom } = useWebRTCCall()
const handleCall = useCallback((phone?: string) => {
setShareSent(false)
setShareError(null)
const { roomId, link } = createRoom()
setCallLink(link)
setTargetPhone(phone || "")
console.log("[call] room created, roomId:", roomId)
console.log("[call] join link:", link)
console.log("[call] target phone:", phone || "none")
}, [createRoom])
const copyLink = useCallback(async () => {
if (!callLink) return
try {
await navigator.clipboard.writeText(callLink)
setShareSent(true)
setShareError(null)
console.log("[share] link copied")
} catch {
setShareError("Failed to copy URL")
console.error("[share] copy failed")
}
}, [callLink])
const sendWhatsApp = useCallback(() => {
if (!callLink) return
const originalPhone = targetPhone
const formattedPhone = formatPhoneForWhatsApp(originalPhone)
const generatedCallLink = callLink
console.log("[wa] original phone:", originalPhone)
console.log("[wa] formatted phone:", formattedPhone)
console.log("[wa] call link:", generatedCallLink)
if (!formattedPhone) {
setShareError("Invalid phone number.")
console.error("[wa] invalid phone number")
return
}
const msg = encodeURIComponent(`Hi! Join me on this link so we can start our call:\n\n${generatedCallLink}`)
const waUrl = `https://wa.me/${formattedPhone}?text=${msg}`
console.log("[wa] WhatsApp URL:", waUrl)
setShowWaUrl(waUrl)
const w = window.open(waUrl, "_blank")
if (!w || w.closed) {
setShareError("Could not open WhatsApp. Please check your browser allows popups.")
console.error("[wa] failed: window.open returned null or closed")
} else {
setShareSent(true)
setShareError(null)
console.log("[wa] success: WhatsApp opened")
}
}, [callLink, targetPhone])
useEffect(() => {
if (!open) {
setSearchQuery("")
setPhoneNumber("")
setPhoneError(false)
setCallLink(null)
setShareSent(false)
setShareError(null)
setTargetPhone("")
setShowWaUrl(null)
}
}, [open])
const FALLBACK_CONTACTS: Contact[] = [
{ id: "fallback-dillen", name: "Dillen", phone: "0799158142", avatar_url: null },
{ id: "fallback-ewan", name: "Ewan", phone: "0845172665", avatar_url: null },
{ id: "fallback-caitlin", name: "Caitlin", phone: "0612729281", avatar_url: null },
]
useEffect(() => {
if (!open) return
const fetchContacts = async () => {
setContactsLoading(true)
setContactsError(false)
try {
const sb = getSupabase()
const { data, error } = await sb
.from("contacts")
.select("id, name, phone, avatar_url")
.order("name", { ascending: true })
if (error) throw error
setContacts([...(data || []), ...FALLBACK_CONTACTS])
} catch {
setContacts(FALLBACK_CONTACTS)
} finally {
setContactsLoading(false)
}
}
fetchContacts()
}, [open])
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose()
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [open, onClose])
const filteredContacts = contacts.filter(
(c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
c.phone.toLowerCase().includes(searchQuery.toLowerCase()),
)
if (!open) return null
return (
<div
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
{callLink ? (
shareSent ? (
<>
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
The recipient must receive and open the call link.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
{callLink}
</div>
<div className="flex gap-2">
<button
onClick={copyLink}
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
</button>
<button
onClick={() => { setShareSent(false); setShareError(null) }}
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<MessageSquare className="h-4 w-4" />
WhatsApp
</button>
</div>
<a
href={callLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
>
Open Call
</a>
</>
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
Select a method to send the call link
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
{callLink}
</div>
{showWaUrl && (
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
{showWaUrl}
</div>
)}
<div className="flex flex-col gap-2 mb-4">
<button
onClick={sendWhatsApp}
className="flex items-center justify-center gap-2 bg-[#25D366] hover:bg-[#1da851] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<MessageSquare className="h-4 w-4" />
WhatsApp
</button>
<button
onClick={copyLink}
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
</button>
</div>
{shareError && (
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
)}
</>
)
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
CONTACTS
</p>
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search contacts..."
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
{contactsLoading && (
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
)}
{contactsError && (
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
)}
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
)}
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
<div
key={contact.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
>
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
{contact.avatar_url ? (
<img
src={contact.avatar_url}
alt={contact.name}
className="w-full h-full rounded-full object-cover"
/>
) : (
contact.name.charAt(0).toUpperCase()
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
</div>
<button
type="button"
onClick={() => handleCall(contact.phone)}
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
>
<Phone className="h-4 w-4" />
</button>
</div>
))}
</div>
<div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
</div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
DIAL A NUMBER
</p>
<input
type="tel"
value={phoneNumber}
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
placeholder="+27 000 000 0000"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
/>
{phoneError && (
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
)}
<button
onClick={() => {
const trimmed = phoneNumber.trim()
if (!trimmed) {
setPhoneError(true)
return
}
setPhoneError(false)
handleCall(trimmed)
}}
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Call Now
</button>
</>
)}
</div>
</div>
)
}
+28 -30
View File
@@ -5,7 +5,6 @@ import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { Sidebar } from "./sidebar"
import { Topbar } from "./topbar"
import { SystemMonitor } from "./system-monitor"
interface AppShellProps {
children: React.ReactNode
@@ -36,51 +35,50 @@ export function AppShell({ children }: AppShellProps) {
return (
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={{
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
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-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
<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="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<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="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<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>
<SystemMonitor />
<Sidebar
collapsed={sidebarCollapsed}
onToggle={toggleSidebar}
+11 -21
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import { motion, AnimatePresence } from "framer-motion"
import { cn } from "@/lib/utils"
import { SystemMonitor } from "./system-monitor"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
@@ -20,7 +21,7 @@ import {
Bot,
Facebook,
} from "lucide-react"
import { COMPANY_NAME } from "@/lib/constants"
import { useUser } from "@/providers/user-provider"
import { useNotifications } from "@/providers/notification-provider"
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
@@ -60,24 +61,11 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
{/* Logo */}
<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">
<img
src="/logo/CompanyLogo.png"
alt={COMPANY_NAME}
className="h-8 w-8 shrink-0 rounded-lg object-contain"
/>
<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>
{collapsed ? (
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
) : (
<div className="bc-logo">Black<span className="accent">Cipher</span></div>
)}
</Link>
{!collapsed && (
<Button
@@ -196,6 +184,8 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
</div>
)}
<SystemMonitor collapsed={collapsed} />
{/* User info */}
<div className={cn("border-t border-sidebar-border p-3", collapsed && "flex justify-center")}>
{collapsed ? (
@@ -222,7 +212,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
return (
<>
{/* 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}
</aside>
@@ -244,7 +234,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
animate={{ x: 0 }}
exit={{ x: -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}
</motion.aside>
+32 -9
View File
@@ -3,9 +3,13 @@
import { useState, useEffect } from "react"
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 [cpuPct, setCpuPct] = useState(0)
@@ -30,14 +34,33 @@ export function SystemMonitor() {
const ramOver = rssMB > RAM_LIMIT_MB
const cpuOver = cpuPct > CPU_LIMIT_PCT
if (collapsed) {
return (
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5">
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{rssMB}
</span>
<span className={cpuOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{cpuPct}%
</span>
</div>
)
}
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">
<span className={ramOver ? "text-red-400" : "text-green-400"}>
RAM: {rssMB}MB / 8192MB
</span>
<span className={cpuOver ? "text-red-400" : "text-green-400"}>
CPU: {cpuPct}%
</span>
<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>
)
}
+15
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { useUser } from "@/providers/user-provider";
import { useNotifications } from "@/providers/notification-provider";
import { BugReportModal } from "@/components/shared/bug-report-modal";
import {
DropdownMenu,
DropdownMenuContent,
@@ -27,6 +28,7 @@ import {
LogOut,
User,
Settings,
Bug,
CheckCheck,
Trash2,
} from "lucide-react";
@@ -43,6 +45,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
useNotifications();
const [mounted, setMounted] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [bugModalOpen, setBugModalOpen] = useState(false);
useEffect(() => {
setMounted(true);
@@ -113,6 +116,17 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Search className="h-5 w-5" />
</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 */}
<Button
variant="ghost"
@@ -247,6 +261,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenuContent>
</DropdownMenu>
</div>
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
</header>
);
}
+76 -1
View File
@@ -6,7 +6,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
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"
@@ -44,8 +45,17 @@ function applyColorTheme(theme: string) {
localStorage.setItem(COLOR_THEME_KEY, theme)
}
const websiteThemes = [
{
id: "spidey",
name: "Spidey",
description: "Current website theme",
},
]
export function ThemeSettings() {
const { theme, setTheme } = useTheme()
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
const [colorTheme, setColorTheme] = useState("default")
const [mounted, setMounted] = useState(false)
@@ -119,6 +129,71 @@ export function ThemeSettings() {
</RadioGroup>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Website Themes</CardTitle>
<CardDescription>
Select your website&apos;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>
)
}
+188
View File
@@ -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>
)
}
+9
View File
@@ -5,6 +5,7 @@ export const users: User[] = [
id: "u-super",
name: "Jason Oosthuizen",
email: "jason@coastalit.com",
phone: "+27 82 555 0100",
role: "super_admin",
active: true,
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
@@ -14,6 +15,7 @@ export const users: User[] = [
id: "u1",
name: "Sarah Chen",
email: "SarahChen@coastit.co.za",
phone: "+27 72 555 0101",
role: "admin",
active: true,
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
@@ -23,6 +25,7 @@ export const users: User[] = [
id: "u2",
name: "Marcus Johnson",
email: "marcus@coastalit.com",
phone: "+27 62 555 0102",
role: "sales",
active: true,
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
@@ -32,6 +35,7 @@ export const users: User[] = [
id: "u3",
name: "Emily Rodriguez",
email: "emily@coastalit.com",
phone: "+27 73 555 0103",
role: "sales",
active: true,
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
@@ -41,6 +45,7 @@ export const users: User[] = [
id: "u4",
name: "David Kim",
email: "david@coastalit.com",
phone: "+27 64 555 0104",
role: "sales",
active: true,
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
@@ -50,6 +55,7 @@ export const users: User[] = [
id: "u5",
name: "Jessica Patel",
email: "jessica@coastalit.com",
phone: "+27 82 555 0105",
role: "sales",
active: false,
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
@@ -59,6 +65,7 @@ export const users: User[] = [
id: "u6",
name: "Alex Thompson",
email: "alex@coastalit.com",
phone: "+27 72 555 0106",
role: "sales",
active: true,
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
@@ -68,6 +75,7 @@ export const users: User[] = [
id: "u7",
name: "Rachel Williams",
email: "rachel@coastalit.com",
phone: "+27 64 555 0107",
role: "sales",
active: true,
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
@@ -77,6 +85,7 @@ export const users: User[] = [
id: "u8",
name: "Tom Nakamura",
email: "tom@coastalit.com",
phone: "+27 83 555 0108",
role: "admin",
active: true,
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
+508
View File
@@ -0,0 +1,508 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { io, Socket } from "socket.io-client"
const STUN_SERVERS = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
}
const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007"
export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined"
export interface PlatformUser {
id: string
username: string
firstName: string
lastName: string
phone: string | null
avatar: string | null
online: boolean
}
export interface CallerInfo {
id: string
username: string
firstName: string
lastName: string
avatar: string | null
}
export interface Participant {
id: string
label: string
}
export function useWebRTCCall(options?: { anonymous?: boolean }) {
const isAnonymous = options?.anonymous ?? false
const [callState, setCallState] = useState<CallState>("idle")
const [isMuted, setIsMuted] = useState(false)
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
const [callDuration, setCallDuration] = useState(0)
const [error, setError] = useState<string | null>(null)
const [isReady, setIsReady] = useState(false)
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
const [calleeNumber, setCalleeNumber] = useState<string>("")
const [tokenLoading, setTokenLoading] = useState(true)
const [participants, setParticipants] = useState<Participant[]>([])
const socketRef = useRef<Socket | null>(null)
const pcRef = useRef<RTCPeerConnection | null>(null)
const localStreamRef = useRef<MediaStream | null>(null)
const audioRef = useRef<HTMLAudioElement | null>(null)
const timerRef = useRef<NodeJS.Timeout | null>(null)
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
const roomIdRef = useRef<string | null>(null)
const startTimer = useCallback(() => {
setCallDuration(0)
if (timerRef.current) clearInterval(timerRef.current)
timerRef.current = setInterval(() => {
setCallDuration((prev) => prev + 1)
}, 1000)
}, [])
useEffect(() => {
let socket: Socket | null = null
let cancelled = false
async function init() {
try {
let authToken: string | undefined
if (!isAnonymous) {
const res = await fetch("/api/auth/jwt")
if (res.ok) {
const data = await res.json()
authToken = data.token
}
if (cancelled) return
}
socket = io(SIGNALING_URL, {
auth: authToken ? { token: authToken } : undefined,
transports: ["websocket", "polling"],
})
socket.on("connect", () => setIsReady(true))
socket.on("disconnect", () => setIsReady(false))
if (!isAnonymous) {
socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => {
setCallerInfo(info)
setCallState("ringing")
pendingCallRef.current = { to: from, sdp }
})
socket.on("call:answered", async ({ from, sdp }) => {
setCallState("connecting")
if (pcRef.current && sdp) {
try {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
} catch {}
}
})
socket.on("call:ice-candidate", async ({ from, candidate }) => {
if (pcRef.current && candidate) {
try {
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
} catch {}
}
})
socket.on("call:ended", ({ from }) => {
setCallState("ended")
cleanupCall()
})
socket.on("call:rejected", ({ from }) => {
setCallState("rejected")
cleanupCall()
})
socket.on("call:busy", ({ from }) => {
setCallState("busy")
cleanupCall()
})
socket.on("call:user-offline", ({ userId }) => {
setCallState("offline")
cleanupCall()
})
}
socket.on("room:waiting", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("waiting")
})
socket.on("room:participant-joined", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("participant_joined")
})
socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("participant_joined")
try {
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && roomIdRef.current) {
socket?.emit("room:ice-candidate", {
roomId: roomIdRef.current,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
pcRef.current = pc
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
setCallState("connecting")
socket?.emit("room:offer", { roomId, sdp: pc.localDescription })
} catch (e) {
setError("Failed to start call")
cleanupCall()
}
})
socket.on("room:offer", async ({ sdp }) => {
try {
setCallState("connecting")
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && roomIdRef.current) {
socket?.emit("room:ice-candidate", {
roomId: roomIdRef.current,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
pcRef.current = pc
await pc.setRemoteDescription(new RTCSessionDescription(sdp))
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription })
setCallState("connected")
startTimer()
} catch (e) {
setError("Failed to join call")
cleanupCall()
}
})
socket.on("room:answer", async ({ sdp }) => {
if (pcRef.current) {
try {
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
setCallState("connected")
startTimer()
} catch {}
}
})
socket.on("room:ice-candidate", async ({ candidate }) => {
if (pcRef.current) {
try {
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
} catch {}
}
})
socket.on("room:participant-left", ({ roomId, participants: pList }) => {
setParticipants(pList || [])
setCallState("ended")
cleanupCall()
})
socketRef.current = socket
} finally {
setTokenLoading(false)
}
}
init()
return () => {
cancelled = true
if (socket) socket.disconnect()
cleanupCall()
}
}, [isAnonymous])
const cleanupCall = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (pcRef.current) {
pcRef.current.close()
pcRef.current = null
}
if (localStreamRef.current) {
localStreamRef.current.getTracks().forEach((t) => t.stop())
localStreamRef.current = null
}
setRemoteStream(null)
setIsMuted(false)
setIsSpeakerOn(false)
if (audioRef.current) {
audioRef.current.pause()
audioRef.current.srcObject = null
audioRef.current = null
}
setCallDuration(0)
pendingCallRef.current = null
roomIdRef.current = null
setParticipants([])
}, [])
const createPeerConnection = useCallback(async () => {
const pc = new RTCPeerConnection(STUN_SERVERS)
pc.onicecandidate = (e) => {
if (e.candidate && pendingCallRef.current) {
socketRef.current?.emit("call:ice-candidate", {
to: pendingCallRef.current.to,
candidate: e.candidate.toJSON(),
})
}
}
pc.ontrack = (e) => {
setRemoteStream(e.streams[0])
const audio = new Audio()
audio.srcObject = e.streams[0]
audio.autoplay = true
audioRef.current = audio
audio.play().catch(() => {})
}
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
setCallState("ended")
cleanupCall()
}
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
localStreamRef.current = stream
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
} catch {
setError("Microphone access denied")
}
pcRef.current = pc
return pc
}, [cleanupCall])
const lookupUser = useCallback(async (phone: string): Promise<PlatformUser | null> => {
return new Promise((resolve) => {
socketRef.current?.emit("user:lookup", { phone }, (result: any) => {
if (result?.found) {
resolve(result.user)
} else {
resolve(null)
}
})
})
}, [])
const makeCall = useCallback(async (phoneNumber: string) => {
try {
setError(null)
setCalleeNumber(phoneNumber)
const user = await lookupUser(phoneNumber)
if (!user) {
setCallState("missed")
setError("Not on Platform")
return
}
if (!user.online) {
setCallState("offline")
setError("User is offline")
return
}
const pc = await createPeerConnection()
setCallState("calling")
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
socketRef.current?.emit("call:offer", {
to: user.id,
sdp: pc.localDescription,
callerInfo: null,
})
pendingCallRef.current = { to: user.id, sdp: null }
setCallState("ringing")
} catch {
setError("Failed to start call")
cleanupCall()
}
}, [createPeerConnection, lookupUser, cleanupCall])
const acceptCall = useCallback(async () => {
if (!pendingCallRef.current) return
setCallState("connecting")
try {
const pc = await createPeerConnection()
await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp))
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
socketRef.current?.emit("call:answer", {
to: pendingCallRef.current.to,
sdp: pc.localDescription,
})
setCallState("connected")
startTimer()
} catch {
setError("Failed to accept call")
cleanupCall()
}
}, [createPeerConnection, cleanupCall, startTimer])
const rejectCall = useCallback(() => {
if (pendingCallRef.current) {
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
}
setCallState("idle")
cleanupCall()
}, [cleanupCall])
const endCall = useCallback(() => {
if (roomIdRef.current) {
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
}
if (!isAnonymous) {
if (callState === "ringing" && pendingCallRef.current) {
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
}
if (callState === "connected" || callState === "connecting" || callState === "calling") {
const to = pendingCallRef.current?.to
if (to) {
socketRef.current?.emit("call:end", { to })
}
}
}
setCallState("ended")
cleanupCall()
}, [callState, cleanupCall, isAnonymous])
const toggleSpeaker = useCallback(() => {
setIsSpeakerOn((prev) => !prev)
}, [])
const toggleMute = useCallback(() => {
if (localStreamRef.current) {
localStreamRef.current.getAudioTracks().forEach((track) => {
track.enabled = isMuted
})
setIsMuted(!isMuted)
}
}, [isMuted])
const formatDuration = useCallback((seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, "0")
const s = (seconds % 60).toString().padStart(2, "0")
return `${m}:${s}`
}, [])
const createRoom = useCallback(() => {
const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14)
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000")
const link = `${baseUrl}/join/${roomId}`
return { roomId, link }
}, [])
const joinRoom = useCallback((roomId: string, displayName?: string) => {
roomIdRef.current = roomId
setCallState("calling")
socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" })
}, [])
const leaveRoom = useCallback(() => {
if (roomIdRef.current) {
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
}
setCallState("ended")
cleanupCall()
}, [cleanupCall])
return {
callState,
isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined",
isIncoming: callState === "ringing" && !!callerInfo,
isMuted,
isSpeakerOn,
callDuration,
error,
isReady,
remoteStream,
callerInfo,
calleeNumber,
participants,
createRoom,
joinRoom,
leaveRoom,
makeCall,
acceptCall,
rejectCall,
endCall,
toggleMute,
toggleSpeaker,
lookupUser,
formatDuration,
tokenLoading,
}
}
+61 -26
View File
@@ -1,41 +1,76 @@
import http from "http"
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) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }),
const body = JSON.stringify({ message })
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
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 text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
const data = JSON.parse(text)
return data.response || ""
}
export async function fetchJobs() {
export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try {
const res = await fetch(`${AI_SERVICE}/ai/jobs`)
if (!res.ok) return []
const data = await res.json()
await new Promise<void>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
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 || []
} catch {
console.warn("Failed to fetch AI jobs")
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
View File
@@ -17,6 +17,7 @@ export interface SessionUser {
id: string;
username: string;
email: string;
phone: string | null;
firstName: string;
lastName: string;
role: string;
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
export async function getUserById(id: string) {
const result = await query(
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
u.is_active, u.avatar_url,
r.name AS role_name
FROM users u
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
id: dbUser.id as string,
username: dbUser.username as string,
email: dbUser.email as string,
phone: (dbUser.phone as string) || null,
firstName: dbUser.first_name as string,
lastName: dbUser.last_name as string,
role: roleMapping[roleName] || roleName.toLowerCase(),
@@ -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) {
const token = await signToken({ userId, role });
@@ -201,7 +224,6 @@ export async function createSession(userId: string, role: string) {
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24, // 24 hours
});
return token;
+1 -1
View File
@@ -19,4 +19,4 @@ export const LEAD_SOURCES = [
export const ITEMS_PER_PAGE = 10
export const COMPANY_NAME = "Coast IT"
export const COMPANY_NAME = "Black Cipher"
+17
View File
@@ -0,0 +1,17 @@
import { createClient, SupabaseClient } from "@supabase/supabase-js"
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
let client: SupabaseClient | null = null
if (supabaseUrl && supabaseAnonKey) {
client = createClient(supabaseUrl, supabaseAnonKey)
}
export function getSupabase(): SupabaseClient {
if (!client) {
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
}
return client
}
+1
View File
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
"/join",
"/api/auth/login",
"/api/auth/logout",
"/_next/static",
+1
View File
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
id: u.id,
name: `${u.firstName} ${u.lastName}`,
email: u.email,
phone: u.phone || "",
role: u.role,
active: true,
avatar: u.avatar,
+92
View File
@@ -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
View File
@@ -4,12 +4,13 @@ export type LeadStatus =
| "pending"
| "closed"
| "ignored";
export type UserRole = "super_admin" | "admin" | "sales";
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;