Merge origin/main into master
This commit is contained in:
+49
-1
@@ -29,7 +29,7 @@ try {
|
||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
||||
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||
@@ -96,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()
|
||||
|
||||
|
||||
+534
-72
@@ -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",
|
||||
@@ -91,6 +242,100 @@ REQUEST_PATTERNS = [
|
||||
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)
|
||||
@@ -207,6 +452,18 @@ def _parse_fb_date(block: list[str]) -> str:
|
||||
pass
|
||||
return datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
|
||||
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'):
|
||||
@@ -239,13 +496,26 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
|
||||
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": text[:300],
|
||||
"content": text[:1000],
|
||||
"author": el.get('author', ''),
|
||||
"url": post_url,
|
||||
"source": "facebook",
|
||||
"date": _parse_fb_date(lines),
|
||||
"date": date_str,
|
||||
"_score": request_score,
|
||||
})
|
||||
posts.sort(key=lambda p: p.get('_score', 0), reverse=True)
|
||||
@@ -306,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)})")
|
||||
@@ -345,31 +622,66 @@ async def _get_article_elements(page) -> list[dict]:
|
||||
const key = text.substring(0, 80);
|
||||
if (seenTexts.has(key)) continue;
|
||||
seenTexts.add(key);
|
||||
const links = el.querySelectorAll('a');
|
||||
|
||||
// --- Publisher name ---
|
||||
let author = '';
|
||||
for (const a of links) {
|
||||
const t = (a.innerText || '').trim();
|
||||
if (t && t.length > 2 && t.length < 80 && /^[a-zA-ZÀ-ÿ][a-zA-ZÀ-ÿ .'-]+$/.test(t)) {
|
||||
author = t;
|
||||
break;
|
||||
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 links) {
|
||||
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/')) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
results.push({ text, author, url: postUrl });
|
||||
|
||||
// --- 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 search_facebook(page, query: str) -> list[dict]:
|
||||
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)
|
||||
@@ -400,9 +712,28 @@ async def search_facebook(page, query: str) -> list[dict]:
|
||||
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 posts
|
||||
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
|
||||
@@ -412,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(5, 8))
|
||||
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 = []
|
||||
@@ -504,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
|
||||
@@ -602,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)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
create table if not exists contacts (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
user_id uuid references auth.users(id) on delete cascade,
|
||||
name text not null,
|
||||
phone text not null,
|
||||
avatar_url text,
|
||||
created_at timestamp default now()
|
||||
);
|
||||
|
||||
alter table contacts enable row level security;
|
||||
|
||||
create policy "Users see own contacts"
|
||||
on contacts for select
|
||||
using (auth.uid() = user_id);
|
||||
|
||||
create policy "Users insert own contacts"
|
||||
on contacts for insert
|
||||
with check (auth.uid() = user_id);
|
||||
|
||||
create policy "Users delete own contacts"
|
||||
on contacts for delete
|
||||
using (auth.uid() = user_id);
|
||||
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
token VARCHAR(64) NOT NULL UNIQUE,
|
||||
phone VARCHAR(50),
|
||||
created_by UUID REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '7 days'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token);
|
||||
Generated
+454
-8
@@ -25,6 +25,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",
|
||||
@@ -40,6 +42,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",
|
||||
@@ -366,6 +370,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -387,6 +392,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -408,6 +414,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -423,6 +430,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -438,6 +446,10 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -453,6 +465,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -468,6 +484,10 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -483,6 +503,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -498,6 +522,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -513,6 +541,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -528,6 +560,10 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -549,6 +585,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -570,6 +610,10 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -591,6 +635,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -612,6 +660,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -633,6 +685,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -654,6 +710,7 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/runtime": "^1.2.0"
|
||||
@@ -672,6 +729,7 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -690,6 +748,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -757,13 +816,15 @@
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
|
||||
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw=="
|
||||
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "15.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
|
||||
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-glob": "3.3.1"
|
||||
}
|
||||
@@ -775,6 +836,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -790,6 +852,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -805,6 +868,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -820,6 +887,10 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -835,6 +906,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -850,6 +925,10 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -865,6 +944,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -880,6 +960,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1860,15 +1941,119 @@
|
||||
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
|
||||
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.108.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.108.2",
|
||||
"@supabase/functions-js": "2.108.2",
|
||||
"@supabase/postgrest-js": "2.108.2",
|
||||
"@supabase/realtime-js": "2.108.2",
|
||||
"@supabase/storage-js": "2.108.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/counter": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
|
||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
|
||||
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1914,6 +2099,15 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
@@ -1990,7 +2184,6 @@
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -2031,6 +2224,15 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
@@ -2589,6 +2791,19 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.17.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
@@ -2946,6 +3161,15 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.37",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz",
|
||||
@@ -3221,6 +3445,7 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
@@ -3252,6 +3477,7 @@
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
@@ -3323,6 +3549,36 @@
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3525,7 +3781,6 @@
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
@@ -3587,6 +3842,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -3662,6 +3918,58 @@
|
||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.9",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.9.tgz",
|
||||
"integrity": "sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.6",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz",
|
||||
"integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.21.0",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-abstract": {
|
||||
"version": "1.24.2",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
|
||||
@@ -3915,6 +4223,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
|
||||
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "15.0.4",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
@@ -4261,6 +4570,7 @@
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "^2.0.2",
|
||||
"@nodelib/fs.walk": "^1.2.3",
|
||||
@@ -4277,6 +4587,7 @@
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
@@ -4717,6 +5028,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -4794,6 +5114,7 @@
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/is-async-function": {
|
||||
@@ -5435,6 +5756,27 @@
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
@@ -5472,8 +5814,7 @@
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
@@ -5524,11 +5865,21 @@
|
||||
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.0.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
|
||||
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.0.4",
|
||||
"@swc/counter": "0.1.3",
|
||||
@@ -6708,6 +7059,7 @@
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"color": "^4.2.3",
|
||||
@@ -6852,11 +7204,68 @@
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.8",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
|
||||
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
@@ -7490,8 +7899,7 @@
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.12.2",
|
||||
@@ -7615,6 +8023,15 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/vaul": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||
@@ -7788,6 +8205,35 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
|
||||
+8
-3
@@ -4,12 +4,13 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||
"dev:signaling": "node signaling-server.mjs",
|
||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT -c cyan,magenta,yellow,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "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 +33,8 @@
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.108.2",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
@@ -47,6 +50,8 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "^1.1.2",
|
||||
|
||||
+1
-1
@@ -446,7 +446,7 @@ async fn main() {
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { createServer } from "http"
|
||||
import { Server } from "socket.io"
|
||||
import { SignJWT, jwtVerify } from "jose"
|
||||
import pg from "pg"
|
||||
|
||||
const PORT = process.env.SIGNALING_PORT || 3007
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||
|
||||
const httpServer = createServer()
|
||||
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
|
||||
|
||||
const onlineUsers = new Map()
|
||||
const rooms = new Map()
|
||||
|
||||
io.use((socket, next) => {
|
||||
const token = socket.handshake.auth?.token
|
||||
if (token) {
|
||||
jwtVerify(token, JWT_SECRET)
|
||||
.then(({ payload }) => {
|
||||
socket.data.userId = payload.userId
|
||||
socket.data.role = payload.role
|
||||
next()
|
||||
})
|
||||
.catch(() => {
|
||||
socket.data.userId = null
|
||||
socket.data.role = null
|
||||
next()
|
||||
})
|
||||
} else {
|
||||
socket.data.userId = null
|
||||
socket.data.role = null
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
io.on("connection", (socket) => {
|
||||
const userId = socket.data.userId
|
||||
|
||||
if (userId) {
|
||||
onlineUsers.set(userId, socket.id)
|
||||
socket.broadcast.emit("user:online", { userId })
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
socket.on("user:lookup", async ({ phone }, callback) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
if (result.rows.length > 0) {
|
||||
const user = result.rows[0]
|
||||
callback({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
online: onlineUsers.has(user.id),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
callback({ found: false })
|
||||
}
|
||||
} catch {
|
||||
callback({ found: false, error: "Lookup failed" })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
let info = callerInfo
|
||||
if (!info) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, username, first_name, last_name, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
if (result.rows.length > 0) {
|
||||
const u = result.rows[0]
|
||||
info = {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
firstName: u.first_name,
|
||||
lastName: u.last_name,
|
||||
avatar: u.avatar_url,
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
io.to(targetSocketId).emit("call:incoming", {
|
||||
from: userId,
|
||||
sdp,
|
||||
callerInfo: info,
|
||||
})
|
||||
} else {
|
||||
socket.emit("call:user-offline", { userId: to })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:answer", ({ to, sdp }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:answered", { from: userId, sdp })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ice-candidate", ({ to, candidate }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:ice-candidate", { from: userId, candidate })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:end", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:ended", { from: userId })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:reject", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:rejected", { from: userId })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:busy", ({ to }) => {
|
||||
const targetSocketId = onlineUsers.get(to)
|
||||
if (targetSocketId) {
|
||||
io.to(targetSocketId).emit("call:busy", { from: userId })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
socket.on("room:join", ({ roomId, displayName }) => {
|
||||
const participantId = userId || `anon-${socket.id}`
|
||||
const participantLabel = displayName || participantId
|
||||
|
||||
if (!rooms.has(roomId)) {
|
||||
rooms.set(roomId, [])
|
||||
}
|
||||
const participants = rooms.get(roomId)
|
||||
participants.push({ socketId: socket.id, id: participantId, label: participantLabel })
|
||||
socket.join(roomId)
|
||||
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
|
||||
if (participants.length === 1) {
|
||||
socket.emit("room:waiting", { roomId, participants: list })
|
||||
} else if (participants.length === 2) {
|
||||
io.to(participants[0].socketId).emit("room:initiate-offer", { roomId, participants: list })
|
||||
io.to(participants[1].socketId).emit("room:participant-joined", { roomId, participants: list })
|
||||
} else {
|
||||
socket.emit("room:participant-joined", { roomId, participants: list })
|
||||
socket.to(roomId).emit("room:participant-joined", { roomId, participants: list })
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:offer", ({ roomId, sdp }) => {
|
||||
socket.to(roomId).emit("room:offer", { sdp })
|
||||
})
|
||||
|
||||
socket.on("room:answer", ({ roomId, sdp }) => {
|
||||
socket.to(roomId).emit("room:answer", { sdp })
|
||||
})
|
||||
|
||||
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
|
||||
socket.to(roomId).emit("room:ice-candidate", { candidate })
|
||||
})
|
||||
|
||||
socket.on("room:leave", ({ roomId }) => {
|
||||
socket.leave(roomId)
|
||||
const participants = rooms.get(roomId)
|
||||
if (participants) {
|
||||
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||
if (idx !== -1) participants.splice(idx, 1)
|
||||
if (participants.length === 0) {
|
||||
rooms.delete(roomId)
|
||||
} else {
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
for (const [roomId, participants] of rooms) {
|
||||
const idx = participants.findIndex(p => p.socketId === socket.id)
|
||||
if (idx !== -1) {
|
||||
participants.splice(idx, 1)
|
||||
if (participants.length === 0) {
|
||||
rooms.delete(roomId)
|
||||
} else {
|
||||
const list = participants.map(p => ({ id: p.id, label: p.label }))
|
||||
io.to(roomId).emit("room:participant-left", { roomId, participants: list })
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userId) {
|
||||
onlineUsers.delete(userId)
|
||||
socket.broadcast.emit("user:offline", { userId })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log(`[signaling] server running on port ${PORT}`)
|
||||
})
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||
CornerDownRight, Forward, Pencil, Download,
|
||||
} from "lucide-react"
|
||||
@@ -28,6 +28,7 @@ import { Textarea } from "@/components/ui/textarea"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { toast } from "sonner"
|
||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
|
||||
@@ -124,6 +125,7 @@ export default function ChatsPage() {
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@@ -642,12 +644,9 @@ export default function ChatsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||
<Phone className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
||||
<Video className="h-4 w-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
@@ -995,6 +994,8 @@ export default function ChatsPage() {
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { chatWithAI } from "@/lib/ai"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
// This route handler has a known issue with Next.js 15 fetch augmentation
|
||||
// on this platform. The client-side code calls the AI server directly
|
||||
// via browser fetch (CORS is open). This handler returns a fallback.
|
||||
export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "AI service unavailable on server. Use client-side mode." }, { status: 503 })
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
const { message } = await request.json()
|
||||
if (!message || typeof message !== "string") {
|
||||
return NextResponse.json({ error: "Message is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
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: any) {
|
||||
console.error("AI chat error:", error)
|
||||
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get("session")?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ token })
|
||||
}
|
||||
@@ -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: "",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
)
|
||||
|
||||
const origin = request.headers.get("origin") || "http://localhost:3000"
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await query(
|
||||
`SELECT id, username, first_name, last_name, phone, avatar_url
|
||||
FROM users
|
||||
WHERE phone = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[phone],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ found: false })
|
||||
}
|
||||
|
||||
const user = result.rows[0]
|
||||
return NextResponse.json({
|
||||
found: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
phone: user.phone,
|
||||
avatar: user.avatar_url,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Lookup failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, use, useCallback } from "react"
|
||||
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
|
||||
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
|
||||
|
||||
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
|
||||
const { roomId } = use(params)
|
||||
|
||||
const {
|
||||
callState,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
participants,
|
||||
joinRoom,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
formatDuration,
|
||||
} = useWebRTCCall({ anonymous: true })
|
||||
|
||||
const [displayName, setDisplayName] = useState("")
|
||||
const [joined, setJoined] = useState(false)
|
||||
const [micError, setMicError] = useState<string | null>(null)
|
||||
const [connectionTimeout, setConnectionTimeout] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isReady) return
|
||||
const timer = setTimeout(() => {
|
||||
if (!isReady) setConnectionTimeout(true)
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [isReady])
|
||||
|
||||
const handleJoin = useCallback(async () => {
|
||||
if (!displayName.trim()) return
|
||||
setMicError(null)
|
||||
try {
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
joinRoom(roomId, displayName.trim() || "Anonymous")
|
||||
setJoined(true)
|
||||
} catch {
|
||||
setMicError("Microphone access is required to join the call. Please allow microphone access in your browser settings.")
|
||||
}
|
||||
}, [displayName, roomId, joinRoom])
|
||||
|
||||
const handleEnd = useCallback(() => {
|
||||
endCall()
|
||||
}, [endCall])
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{connectionTimeout ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
</>
|
||||
) : !isReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||
{micError && (
|
||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||
placeholder="Enter your name"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!displayName.trim()}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Join Call
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{callState === "calling" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "waiting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "participant_joined" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connecting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{callState === "connected" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(callState === "ended" || callState === "idle") && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||
{callDuration > 0 && (
|
||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { query } from "@/lib/db"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>
|
||||
}
|
||||
|
||||
function ErrorPage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function JoinPage({ params }: Props) {
|
||||
const { token } = await params
|
||||
|
||||
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
if (UUID_REGEX.test(token)) {
|
||||
redirect(`/call/${token}`)
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
|
||||
[token],
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return <ErrorPage message="This call link is invalid." />
|
||||
}
|
||||
|
||||
const invite = result.rows[0]
|
||||
|
||||
if (new Date(invite.expires_at) <= new Date()) {
|
||||
return <ErrorPage message="This call has expired." />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p className="text-[#888888] text-sm mb-6">
|
||||
Someone wants to connect with you on our platform.
|
||||
</p>
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] mb-6">
|
||||
Create an account to start making free voice calls to this person and others on the platform.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
@@ -14,7 +14,8 @@ const waves = [
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
@@ -215,7 +216,7 @@ export default function LoginPage() {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
window.location.href = redirectTo
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
|
||||
+111
-32
@@ -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,11 +24,28 @@ 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(`${AI_API}/ai/jobs`)
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.jobs?.length) {
|
||||
@@ -56,28 +73,13 @@ export function AIChat() {
|
||||
},
|
||||
])
|
||||
})
|
||||
checkServer()
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const AI_API = process.env.NEXT_PUBLIC_AI_URL || "http://127.0.0.1:3001"
|
||||
|
||||
const checkOllama = async () => {
|
||||
try {
|
||||
const res = await fetch(`${AI_API}/health`)
|
||||
const data = await res.json()
|
||||
setOllamaStatus(data.status === "ok")
|
||||
} catch {
|
||||
setOllamaStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkOllama() }, [])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const msg = input.trim()
|
||||
if (!msg || loading) return
|
||||
@@ -88,7 +90,7 @@ export function AIChat() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${AI_API}/ai/chat`, {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: msg }),
|
||||
@@ -120,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) => (
|
||||
@@ -162,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>
|
||||
)}
|
||||
@@ -191,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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -36,47 +36,47 @@ 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>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ export const users: User[] = [
|
||||
id: "u-super",
|
||||
name: "Jason Oosthuizen",
|
||||
email: "jason@coastalit.com",
|
||||
phone: "+27 82 555 0100",
|
||||
role: "super_admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Jason+Oosthuizen&background=0f172a&color=fff&size=128",
|
||||
@@ -14,6 +15,7 @@ export const users: User[] = [
|
||||
id: "u1",
|
||||
name: "Sarah Chen",
|
||||
email: "SarahChen@coastit.co.za",
|
||||
phone: "+27 72 555 0101",
|
||||
role: "admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Sarah+Chen&background=1d4ed8&color=fff&size=128",
|
||||
@@ -23,6 +25,7 @@ export const users: User[] = [
|
||||
id: "u2",
|
||||
name: "Marcus Johnson",
|
||||
email: "marcus@coastalit.com",
|
||||
phone: "+27 62 555 0102",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Marcus+Johnson&background=2563eb&color=fff&size=128",
|
||||
@@ -32,6 +35,7 @@ export const users: User[] = [
|
||||
id: "u3",
|
||||
name: "Emily Rodriguez",
|
||||
email: "emily@coastalit.com",
|
||||
phone: "+27 73 555 0103",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Emily+Rodriguez&background=3b82f6&color=fff&size=128",
|
||||
@@ -41,6 +45,7 @@ export const users: User[] = [
|
||||
id: "u4",
|
||||
name: "David Kim",
|
||||
email: "david@coastalit.com",
|
||||
phone: "+27 64 555 0104",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=David+Kim&background=60a5fa&color=fff&size=128",
|
||||
@@ -50,6 +55,7 @@ export const users: User[] = [
|
||||
id: "u5",
|
||||
name: "Jessica Patel",
|
||||
email: "jessica@coastalit.com",
|
||||
phone: "+27 82 555 0105",
|
||||
role: "sales",
|
||||
active: false,
|
||||
avatar: "https://ui-avatars.com/api/?name=Jessica+Patel&background=93c5fd&color=fff&size=128",
|
||||
@@ -59,6 +65,7 @@ export const users: User[] = [
|
||||
id: "u6",
|
||||
name: "Alex Thompson",
|
||||
email: "alex@coastalit.com",
|
||||
phone: "+27 72 555 0106",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Alex+Thompson&background=1d4ed8&color=fff&size=128",
|
||||
@@ -68,6 +75,7 @@ export const users: User[] = [
|
||||
id: "u7",
|
||||
name: "Rachel Williams",
|
||||
email: "rachel@coastalit.com",
|
||||
phone: "+27 64 555 0107",
|
||||
role: "sales",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Rachel+Williams&background=2563eb&color=fff&size=128",
|
||||
@@ -77,6 +85,7 @@ export const users: User[] = [
|
||||
id: "u8",
|
||||
name: "Tom Nakamura",
|
||||
email: "tom@coastalit.com",
|
||||
phone: "+27 83 555 0108",
|
||||
role: "admin",
|
||||
active: true,
|
||||
avatar: "https://ui-avatars.com/api/?name=Tom+Nakamura&background=3b82f6&color=fff&size=128",
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { io, Socket } from "socket.io-client"
|
||||
|
||||
const STUN_SERVERS = {
|
||||
iceServers: [
|
||||
{ urls: "stun:stun.l.google.com:19302" },
|
||||
{ urls: "stun:stun1.l.google.com:19302" },
|
||||
],
|
||||
}
|
||||
|
||||
const SIGNALING_URL = process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007"
|
||||
|
||||
export type CallState = "idle" | "calling" | "ringing" | "connecting" | "connected" | "ended" | "missed" | "rejected" | "offline" | "busy" | "waiting" | "participant_joined"
|
||||
|
||||
export interface PlatformUser {
|
||||
id: string
|
||||
username: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
phone: string | null
|
||||
avatar: string | null
|
||||
online: boolean
|
||||
}
|
||||
|
||||
export interface CallerInfo {
|
||||
id: string
|
||||
username: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
avatar: string | null
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export function useWebRTCCall(options?: { anonymous?: boolean }) {
|
||||
const isAnonymous = options?.anonymous ?? false
|
||||
|
||||
const [callState, setCallState] = useState<CallState>("idle")
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [isSpeakerOn, setIsSpeakerOn] = useState(false)
|
||||
const [callDuration, setCallDuration] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isReady, setIsReady] = useState(false)
|
||||
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null)
|
||||
const [callerInfo, setCallerInfo] = useState<CallerInfo | null>(null)
|
||||
const [calleeNumber, setCalleeNumber] = useState<string>("")
|
||||
const [tokenLoading, setTokenLoading] = useState(true)
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
|
||||
const socketRef = useRef<Socket | null>(null)
|
||||
const pcRef = useRef<RTCPeerConnection | null>(null)
|
||||
const localStreamRef = useRef<MediaStream | null>(null)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const pendingCallRef = useRef<{ to: string; sdp: any } | null>(null)
|
||||
const roomIdRef = useRef<string | null>(null)
|
||||
|
||||
const startTimer = useCallback(() => {
|
||||
setCallDuration(0)
|
||||
if (timerRef.current) clearInterval(timerRef.current)
|
||||
timerRef.current = setInterval(() => {
|
||||
setCallDuration((prev) => prev + 1)
|
||||
}, 1000)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let socket: Socket | null = null
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
let authToken: string | undefined
|
||||
|
||||
if (!isAnonymous) {
|
||||
const res = await fetch("/api/auth/jwt")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
authToken = data.token
|
||||
}
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
socket = io(SIGNALING_URL, {
|
||||
auth: authToken ? { token: authToken } : undefined,
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
|
||||
socket.on("connect", () => setIsReady(true))
|
||||
socket.on("disconnect", () => setIsReady(false))
|
||||
|
||||
if (!isAnonymous) {
|
||||
socket.on("call:incoming", ({ from, sdp, callerInfo: info }) => {
|
||||
setCallerInfo(info)
|
||||
setCallState("ringing")
|
||||
pendingCallRef.current = { to: from, sdp }
|
||||
})
|
||||
|
||||
socket.on("call:answered", async ({ from, sdp }) => {
|
||||
setCallState("connecting")
|
||||
if (pcRef.current && sdp) {
|
||||
try {
|
||||
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ice-candidate", async ({ from, candidate }) => {
|
||||
if (pcRef.current && candidate) {
|
||||
try {
|
||||
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("call:ended", ({ from }) => {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:rejected", ({ from }) => {
|
||||
setCallState("rejected")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:busy", ({ from }) => {
|
||||
setCallState("busy")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socket.on("call:user-offline", ({ userId }) => {
|
||||
setCallState("offline")
|
||||
cleanupCall()
|
||||
})
|
||||
}
|
||||
|
||||
socket.on("room:waiting", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("waiting")
|
||||
})
|
||||
|
||||
socket.on("room:participant-joined", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("participant_joined")
|
||||
})
|
||||
|
||||
socket.on("room:initiate-offer", async ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("participant_joined")
|
||||
try {
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && roomIdRef.current) {
|
||||
socket?.emit("room:ice-candidate", {
|
||||
roomId: roomIdRef.current,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
pcRef.current = pc
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
setCallState("connecting")
|
||||
socket?.emit("room:offer", { roomId, sdp: pc.localDescription })
|
||||
} catch (e) {
|
||||
setError("Failed to start call")
|
||||
cleanupCall()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:offer", async ({ sdp }) => {
|
||||
try {
|
||||
setCallState("connecting")
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && roomIdRef.current) {
|
||||
socket?.emit("room:ice-candidate", {
|
||||
roomId: roomIdRef.current,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
pcRef.current = pc
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
socket?.emit("room:answer", { roomId: roomIdRef.current, sdp: pc.localDescription })
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch (e) {
|
||||
setError("Failed to join call")
|
||||
cleanupCall()
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:answer", async ({ sdp }) => {
|
||||
if (pcRef.current) {
|
||||
try {
|
||||
await pcRef.current.setRemoteDescription(new RTCSessionDescription(sdp))
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:ice-candidate", async ({ candidate }) => {
|
||||
if (pcRef.current) {
|
||||
try {
|
||||
await pcRef.current.addIceCandidate(new RTCIceCandidate(candidate))
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("room:participant-left", ({ roomId, participants: pList }) => {
|
||||
setParticipants(pList || [])
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
})
|
||||
|
||||
socketRef.current = socket
|
||||
} finally {
|
||||
setTokenLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (socket) socket.disconnect()
|
||||
cleanupCall()
|
||||
}
|
||||
}, [isAnonymous])
|
||||
|
||||
const cleanupCall = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
if (pcRef.current) {
|
||||
pcRef.current.close()
|
||||
pcRef.current = null
|
||||
}
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach((t) => t.stop())
|
||||
localStreamRef.current = null
|
||||
}
|
||||
setRemoteStream(null)
|
||||
setIsMuted(false)
|
||||
setIsSpeakerOn(false)
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.srcObject = null
|
||||
audioRef.current = null
|
||||
}
|
||||
setCallDuration(0)
|
||||
pendingCallRef.current = null
|
||||
roomIdRef.current = null
|
||||
setParticipants([])
|
||||
}, [])
|
||||
|
||||
const createPeerConnection = useCallback(async () => {
|
||||
const pc = new RTCPeerConnection(STUN_SERVERS)
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate && pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:ice-candidate", {
|
||||
to: pendingCallRef.current.to,
|
||||
candidate: e.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
setRemoteStream(e.streams[0])
|
||||
const audio = new Audio()
|
||||
audio.srcObject = e.streams[0]
|
||||
audio.autoplay = true
|
||||
audioRef.current = audio
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
pc.oniceconnectionstatechange = () => {
|
||||
if (pc.iceConnectionState === "disconnected" || pc.iceConnectionState === "failed") {
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
localStreamRef.current = stream
|
||||
stream.getTracks().forEach((track) => pc.addTrack(track, stream))
|
||||
} catch {
|
||||
setError("Microphone access denied")
|
||||
}
|
||||
|
||||
pcRef.current = pc
|
||||
return pc
|
||||
}, [cleanupCall])
|
||||
|
||||
const lookupUser = useCallback(async (phone: string): Promise<PlatformUser | null> => {
|
||||
return new Promise((resolve) => {
|
||||
socketRef.current?.emit("user:lookup", { phone }, (result: any) => {
|
||||
if (result?.found) {
|
||||
resolve(result.user)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [])
|
||||
|
||||
const makeCall = useCallback(async (phoneNumber: string) => {
|
||||
try {
|
||||
setError(null)
|
||||
setCalleeNumber(phoneNumber)
|
||||
|
||||
const user = await lookupUser(phoneNumber)
|
||||
if (!user) {
|
||||
setCallState("missed")
|
||||
setError("Not on Platform")
|
||||
return
|
||||
}
|
||||
if (!user.online) {
|
||||
setCallState("offline")
|
||||
setError("User is offline")
|
||||
return
|
||||
}
|
||||
|
||||
const pc = await createPeerConnection()
|
||||
setCallState("calling")
|
||||
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
|
||||
socketRef.current?.emit("call:offer", {
|
||||
to: user.id,
|
||||
sdp: pc.localDescription,
|
||||
callerInfo: null,
|
||||
})
|
||||
pendingCallRef.current = { to: user.id, sdp: null }
|
||||
|
||||
setCallState("ringing")
|
||||
} catch {
|
||||
setError("Failed to start call")
|
||||
cleanupCall()
|
||||
}
|
||||
}, [createPeerConnection, lookupUser, cleanupCall])
|
||||
|
||||
const acceptCall = useCallback(async () => {
|
||||
if (!pendingCallRef.current) return
|
||||
setCallState("connecting")
|
||||
|
||||
try {
|
||||
const pc = await createPeerConnection()
|
||||
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(pendingCallRef.current.sdp))
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
|
||||
socketRef.current?.emit("call:answer", {
|
||||
to: pendingCallRef.current.to,
|
||||
sdp: pc.localDescription,
|
||||
})
|
||||
|
||||
setCallState("connected")
|
||||
startTimer()
|
||||
} catch {
|
||||
setError("Failed to accept call")
|
||||
cleanupCall()
|
||||
}
|
||||
}, [createPeerConnection, cleanupCall, startTimer])
|
||||
|
||||
const rejectCall = useCallback(() => {
|
||||
if (pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||
}
|
||||
setCallState("idle")
|
||||
cleanupCall()
|
||||
}, [cleanupCall])
|
||||
|
||||
const endCall = useCallback(() => {
|
||||
if (roomIdRef.current) {
|
||||
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||
}
|
||||
if (!isAnonymous) {
|
||||
if (callState === "ringing" && pendingCallRef.current) {
|
||||
socketRef.current?.emit("call:reject", { to: pendingCallRef.current.to })
|
||||
}
|
||||
if (callState === "connected" || callState === "connecting" || callState === "calling") {
|
||||
const to = pendingCallRef.current?.to
|
||||
if (to) {
|
||||
socketRef.current?.emit("call:end", { to })
|
||||
}
|
||||
}
|
||||
}
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}, [callState, cleanupCall, isAnonymous])
|
||||
|
||||
const toggleSpeaker = useCallback(() => {
|
||||
setIsSpeakerOn((prev) => !prev)
|
||||
}, [])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = isMuted
|
||||
})
|
||||
setIsMuted(!isMuted)
|
||||
}
|
||||
}, [isMuted])
|
||||
|
||||
const formatDuration = useCallback((seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, "0")
|
||||
const s = (seconds % 60).toString().padStart(2, "0")
|
||||
return `${m}:${s}`
|
||||
}, [])
|
||||
|
||||
const createRoom = useCallback(() => {
|
||||
const roomId = crypto.randomUUID?.() || Math.random().toString(36).slice(2, 14)
|
||||
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || (typeof window !== "undefined" ? window.location.origin : "http://localhost:3000")
|
||||
const link = `${baseUrl}/join/${roomId}`
|
||||
return { roomId, link }
|
||||
}, [])
|
||||
|
||||
const joinRoom = useCallback((roomId: string, displayName?: string) => {
|
||||
roomIdRef.current = roomId
|
||||
setCallState("calling")
|
||||
socketRef.current?.emit("room:join", { roomId, displayName: displayName || "" })
|
||||
}, [])
|
||||
|
||||
const leaveRoom = useCallback(() => {
|
||||
if (roomIdRef.current) {
|
||||
socketRef.current?.emit("room:leave", { roomId: roomIdRef.current })
|
||||
}
|
||||
setCallState("ended")
|
||||
cleanupCall()
|
||||
}, [cleanupCall])
|
||||
|
||||
return {
|
||||
callState,
|
||||
isCallActive: callState === "connected" || callState === "connecting" || callState === "ringing" || callState === "calling" || callState === "waiting" || callState === "participant_joined",
|
||||
isIncoming: callState === "ringing" && !!callerInfo,
|
||||
isMuted,
|
||||
isSpeakerOn,
|
||||
callDuration,
|
||||
error,
|
||||
isReady,
|
||||
remoteStream,
|
||||
callerInfo,
|
||||
calleeNumber,
|
||||
participants,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
makeCall,
|
||||
acceptCall,
|
||||
rejectCall,
|
||||
endCall,
|
||||
toggleMute,
|
||||
toggleSpeaker,
|
||||
lookupUser,
|
||||
formatDuration,
|
||||
tokenLoading,
|
||||
}
|
||||
}
|
||||
+58
-27
@@ -1,45 +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 url = `${AI_SERVICE}/ai/chat`
|
||||
const body = JSON.stringify({ message })
|
||||
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwtToken}` },
|
||||
body,
|
||||
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()
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`AI error ${res.status}: ${text.substring(0, 200)}`)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -17,6 +17,7 @@ export interface SessionUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
role: string;
|
||||
@@ -85,7 +86,7 @@ export async function getUserByUsername(username: string) {
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.first_name, u.last_name,
|
||||
` SELECT u.id, u.username, u.email, u.phone, u.first_name, u.last_name,
|
||||
u.is_active, u.avatar_url,
|
||||
r.name AS role_name
|
||||
FROM users u
|
||||
@@ -185,6 +186,7 @@ export function mapDbUserToSessionUser(
|
||||
id: dbUser.id as string,
|
||||
username: dbUser.username as string,
|
||||
email: dbUser.email as string,
|
||||
phone: (dbUser.phone as string) || null,
|
||||
firstName: dbUser.first_name as string,
|
||||
lastName: dbUser.last_name as string,
|
||||
role: roleMapping[roleName] || roleName.toLowerCase(),
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createClient, SupabaseClient } from "@supabase/supabase-js"
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ""
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || ""
|
||||
|
||||
let client: SupabaseClient | null = null
|
||||
|
||||
if (supabaseUrl && supabaseAnonKey) {
|
||||
client = createClient(supabaseUrl, supabaseAnonKey)
|
||||
}
|
||||
|
||||
export function getSupabase(): SupabaseClient {
|
||||
if (!client) {
|
||||
throw new Error("Supabase is not configured. Set NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY environment variables.")
|
||||
}
|
||||
return client
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
"/join",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/_next/static",
|
||||
|
||||
@@ -30,6 +30,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
||||
id: u.id,
|
||||
name: `${u.firstName} ${u.lastName}`,
|
||||
email: u.email,
|
||||
phone: u.phone || "",
|
||||
role: u.role,
|
||||
active: true,
|
||||
avatar: u.avatar,
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
avatar: string;
|
||||
|
||||
Reference in New Issue
Block a user