Compare commits
18 Commits
1adc4806fa
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d9a1dc684 | |||
| 203eac2132 | |||
| 97e8da3bfd | |||
| 3a927b25b6 | |||
| b4be369f25 | |||
| 4fd9f7752a | |||
| e78503b5c1 | |||
| d51c84997a | |||
| c355d0e2e5 | |||
| ff56cea4b8 | |||
| 7bd9c17b5f | |||
| 1c717ce7ba | |||
| cc56fe6286 | |||
| cf554a70cd | |||
| 3061ab111c | |||
| 491ff52b90 | |||
| 9cbae2022b | |||
| d6e4908c18 |
@@ -0,0 +1 @@
|
|||||||
|
rust-ai/target/** linguist-generated=true -diff -merge
|
||||||
+6
-1
@@ -58,4 +58,9 @@ next-env.d.ts
|
|||||||
.git
|
.git
|
||||||
.git_bak
|
.git_bak
|
||||||
node_modules
|
node_modules
|
||||||
target
|
target
|
||||||
|
# rust build artifacts
|
||||||
|
rust-ai/target/
|
||||||
|
|
||||||
|
# browser-use-service generated files
|
||||||
|
browser-use-service/*.txt
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import http from "node:http"
|
||||||
|
import fs from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
|
// ── Config from env ─────────────────────────────────────────────
|
||||||
|
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||||
|
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||||
|
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||||
|
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||||
|
|
||||||
|
// ── Job loading ─────────────────────────────────────────────────
|
||||||
|
function loadJobs() {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
|
const jobs = content
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((l) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(l)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
console.log(`Loaded ${jobs.length} job categories`)
|
||||||
|
return jobs
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ai.md management ────────────────────────────────────────────
|
||||||
|
function readInstructions() {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
|
} catch {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeInstructions(content) {
|
||||||
|
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendToImprovementLog(entry) {
|
||||||
|
const current = readInstructions()
|
||||||
|
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||||
|
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||||
|
const logSection = "\n## Improvement Log"
|
||||||
|
const logIndex = current.indexOf(logSection)
|
||||||
|
if (logIndex !== -1) {
|
||||||
|
const afterLog = current.substring(logIndex + logSection.length)
|
||||||
|
const nextSectionIndex = afterLog.search(/\n## /)
|
||||||
|
if (nextSectionIndex !== -1) {
|
||||||
|
const insertAt = logIndex + logSection.length + nextSectionIndex
|
||||||
|
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
writeInstructions(current + logEntry + "\n")
|
||||||
|
return current + logEntry + "\n"
|
||||||
|
}
|
||||||
|
const updated = current + `\n${logSection}\n${logEntry}\n`
|
||||||
|
writeInstructions(updated)
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Chat handler ────────────────────────────────────────────────
|
||||||
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
const jobs = loadedJobs
|
||||||
|
const instructions = readInstructions()
|
||||||
|
|
||||||
|
const jobList = jobs
|
||||||
|
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
||||||
|
|
||||||
|
Available job categories to target:
|
||||||
|
${jobList}
|
||||||
|
|
||||||
|
Current instructions:
|
||||||
|
${instructions}
|
||||||
|
|
||||||
|
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
||||||
|
|
||||||
|
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userMessage },
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
options: { temperature: 0.7, num_predict: 1024 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!ollamaRes.ok) {
|
||||||
|
const text = await ollamaRes.text()
|
||||||
|
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await ollamaRes.json()
|
||||||
|
const responseText = data.message?.content || ""
|
||||||
|
|
||||||
|
// Try to persist to DB (best-effort)
|
||||||
|
try {
|
||||||
|
if (pgPool && userId) {
|
||||||
|
await pgPool.query(
|
||||||
|
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||||
|
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// table might not exist, ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseText
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
|
let pgPool = null
|
||||||
|
async function initPg() {
|
||||||
|
if (!DATABASE_URL) return
|
||||||
|
try {
|
||||||
|
const { default: pg } = await import("pg")
|
||||||
|
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
||||||
|
await pgPool.query("SELECT 1")
|
||||||
|
console.log("Connected to PostgreSQL")
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Request router ─────────────────────────────────────────────
|
||||||
|
const loadedJobs = loadJobs()
|
||||||
|
|
||||||
|
function sendJSON(res, status, data) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json" })
|
||||||
|
res.end(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let body = ""
|
||||||
|
req.on("data", (chunk) => (body += chunk))
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body))
|
||||||
|
} catch {
|
||||||
|
reject(new Error("Invalid JSON"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
req.on("error", reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseURL(req) {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
// CORS
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.writeHead(204)
|
||||||
|
res.end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname } = parseURL(req)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// GET /health
|
||||||
|
if (req.method === "GET" && pathname === "/health") {
|
||||||
|
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/jobs
|
||||||
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/instructions
|
||||||
|
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||||
|
const instructions = readInstructions()
|
||||||
|
return sendJSON(res, 200, { success: true, instructions })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/instructions
|
||||||
|
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
if (body.content) {
|
||||||
|
writeInstructions(body.content)
|
||||||
|
} else if (body.entry) {
|
||||||
|
appendToImprovementLog(body.entry)
|
||||||
|
}
|
||||||
|
return sendJSON(res, 200, {
|
||||||
|
success: true,
|
||||||
|
instructions: readInstructions(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /ai/chat
|
||||||
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const { message, user_id, user_role } = body
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return sendJSON(res, 400, { error: "message is required" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRoles = ["sales", "admin", "super_admin"]
|
||||||
|
if (user_role && !validRoles.includes(user_role)) {
|
||||||
|
return sendJSON(res, 403, { error: "Forbidden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||||
|
return sendJSON(res, 200, { response })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
sendJSON(res, 404, { error: "Not found" })
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Request error:", err)
|
||||||
|
sendJSON(res, 500, { error: err.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Start ───────────────────────────────────────────────────────
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||||
|
console.log(` Model: ${MODEL}`)
|
||||||
|
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
initPg()
|
||||||
Binary file not shown.
+169
-134
@@ -1,9 +1,7 @@
|
|||||||
import os, json, asyncio, re, shutil, sqlite3, traceback, urllib.parse, random, time, logging
|
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from bs4 import BeautifulSoup
|
from fastapi import FastAPI, Query
|
||||||
import httpx
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi import FastAPI
|
|
||||||
from pydantic import BaseModel
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
@@ -11,22 +9,20 @@ logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(me
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
|
||||||
|
allow_methods=["POST"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
PORT = int(os.getenv("PORT", "3008"))
|
PORT = int(os.getenv("PORT", "3008"))
|
||||||
|
|
||||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||||
|
|
||||||
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||||
FX_COOKIE_DB = os.path.join(FX_PROFILE, 'cookies.sqlite') if FX_PROFILE else ''
|
|
||||||
|
|
||||||
HEADERS = {
|
BROAD_KEYWORDS = [
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
||||||
}
|
|
||||||
|
|
||||||
last_scrape_time: datetime | None = None
|
|
||||||
|
|
||||||
STRICT_KEYWORDS = [
|
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
"need web", "looking for web", "new website",
|
"need web", "looking for web", "new website",
|
||||||
@@ -34,9 +30,6 @@ STRICT_KEYWORDS = [
|
|||||||
"need a website", "my website", "business website",
|
"need a website", "my website", "business website",
|
||||||
"need a designer", "help with my website",
|
"need a designer", "help with my website",
|
||||||
"redesign", "update my website",
|
"redesign", "update my website",
|
||||||
]
|
|
||||||
|
|
||||||
BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
|
||||||
"looking for", "need a", "need an", "looking to",
|
"looking for", "need a", "need an", "looking to",
|
||||||
"help me build", "create my", "build me",
|
"help me build", "create my", "build me",
|
||||||
"design my", "make me a", "would like",
|
"design my", "make me a", "would like",
|
||||||
@@ -44,10 +37,6 @@ BROAD_KEYWORDS = STRICT_KEYWORDS + [
|
|||||||
"want someone", "need help with",
|
"want someone", "need help with",
|
||||||
]
|
]
|
||||||
|
|
||||||
def kw_match_strict(text: str) -> bool:
|
|
||||||
t = text.lower()
|
|
||||||
return any(kw in t for kw in STRICT_KEYWORDS)
|
|
||||||
|
|
||||||
def kw_match(text: str) -> bool:
|
def kw_match(text: str) -> bool:
|
||||||
t = text.lower()
|
t = text.lower()
|
||||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||||
@@ -67,14 +56,27 @@ FB_SEARCHES = [
|
|||||||
"need a site for my business",
|
"need a site for my business",
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_fb_cookies():
|
VIEWPORTS = [
|
||||||
if not FX_COOKIE_DB or not os.path.exists(FX_COOKIE_DB):
|
{'width': 1280, 'height': 800},
|
||||||
logger.warning("FX_COOKIE_DB not found or FX_PROFILE not set")
|
{'width': 1366, 'height': 768},
|
||||||
|
{'width': 1440, 'height': 900},
|
||||||
|
{'width': 1536, 'height': 864},
|
||||||
|
{'width': 1920, 'height': 1080},
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_fb_cookies(profile_path: str | None = None):
|
||||||
|
cookie_db_path = profile_path or FX_PROFILE
|
||||||
|
if not cookie_db_path:
|
||||||
|
logger.warning("No profile path provided")
|
||||||
return []
|
return []
|
||||||
tmp = os.path.join(os.path.dirname(FX_COOKIE_DB), f'cookies_tmp_{os.getpid()}.sqlite')
|
cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite')
|
||||||
|
if not os.path.exists(cookie_db):
|
||||||
|
logger.warning("Cookie DB not found at %s", cookie_db)
|
||||||
|
return []
|
||||||
|
tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite')
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
shutil.copy2(FX_COOKIE_DB, tmp)
|
shutil.copy2(cookie_db, tmp)
|
||||||
break
|
break
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
if attempt < 2:
|
if attempt < 2:
|
||||||
@@ -182,35 +184,95 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
})
|
})
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
|
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
||||||
|
steps = steps or random.randint(2, 5)
|
||||||
|
total_delay = total_delay or random.uniform(6, 18)
|
||||||
|
step_delay = total_delay / steps
|
||||||
|
for i in range(steps):
|
||||||
|
scroll_dist = random.randint(200, 600)
|
||||||
|
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
||||||
|
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)})")
|
||||||
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
|
|
||||||
|
async def random_idle(page):
|
||||||
|
action = random.random()
|
||||||
|
if action < 0.15:
|
||||||
|
try:
|
||||||
|
elems = await page.query_selector_all('a, span, div[role="button"]')
|
||||||
|
if elems:
|
||||||
|
el = random.choice(elems[:20])
|
||||||
|
box = await el.bounding_box()
|
||||||
|
if box:
|
||||||
|
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
|
||||||
|
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
|
||||||
|
await page.mouse.move(x, y, steps=random.randint(20, 40))
|
||||||
|
await page.wait_for_timeout(random.randint(500, 2000))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def search_facebook(page, query: str) -> list[dict]:
|
async def search_facebook(page, query: str) -> list[dict]:
|
||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(6000)
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Facebook search navigation failed for '%s': %s", query, e)
|
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
||||||
return []
|
|
||||||
|
# Accidental overscroll: 10% chance to jump to bottom and back
|
||||||
|
if random.random() < 0.1:
|
||||||
|
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
|
await page.evaluate("window.scrollBy(0, -300)")
|
||||||
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
|
|
||||||
|
if random.random() < 0.2:
|
||||||
|
await page.evaluate("window.scrollTo(0, 0)")
|
||||||
|
await page.wait_for_timeout(random.randint(2000, 5000))
|
||||||
|
|
||||||
|
if random.random() < 0.3:
|
||||||
|
await random_idle(page)
|
||||||
|
|
||||||
try:
|
|
||||||
raw = await page.evaluate('document.body.innerText')
|
raw = await page.evaluate('document.body.innerText')
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Failed to evaluate page text: %s", e)
|
logger.warning("Facebook search failed: %s", e)
|
||||||
return []
|
return []
|
||||||
return _extract_posts_from_text(raw, url)
|
return _extract_posts_from_text(raw, url)
|
||||||
|
|
||||||
async def scrape_facebook() -> list[dict]:
|
def cleanup_chrome():
|
||||||
all_posts = []
|
import subprocess, signal
|
||||||
fb_cookies = await get_fb_cookies()
|
try:
|
||||||
|
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
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:
|
if not fb_cookies:
|
||||||
logger.warning("No Facebook cookies available")
|
logger.warning("No Facebook cookies available")
|
||||||
return []
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
|
||||||
try:
|
try:
|
||||||
async with async_playwright() as pw:
|
async with async_playwright() as pw:
|
||||||
browser = await pw.chromium.launch(headless=True)
|
browser = await pw.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=['--disable-blink-features=AutomationControlled']
|
||||||
|
)
|
||||||
|
viewport = random.choice(VIEWPORTS)
|
||||||
context = await browser.new_context(
|
context = await browser.new_context(
|
||||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
||||||
viewport={'width': 1280, 'height': 800},
|
viewport=viewport,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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:
|
for c in fb_cookies:
|
||||||
try:
|
try:
|
||||||
await context.add_cookies([c])
|
await context.add_cookies([c])
|
||||||
@@ -218,36 +280,77 @@ async def scrape_facebook() -> list[dict]:
|
|||||||
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
||||||
|
|
||||||
page = await context.new_page()
|
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.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||||
await page.wait_for_timeout(5000)
|
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||||
|
|
||||||
url = page.url
|
url = page.url
|
||||||
if '/login' in url.lower():
|
if '/login' in url.lower():
|
||||||
logger.warning("Facebook login page detected — cookies may be expired")
|
logger.warning("Facebook login page detected — account flagged")
|
||||||
return []
|
await browser.close()
|
||||||
|
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
|
||||||
|
|
||||||
for query in FB_SEARCHES[:6]:
|
# 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()
|
||||||
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
|
all_posts = []
|
||||||
|
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
|
||||||
|
for i, query in enumerate(searches):
|
||||||
try:
|
try:
|
||||||
posts = await search_facebook(page, query)
|
posts = await search_facebook(page, query)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
delay = random.uniform(3, 7)
|
# 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))
|
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)
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
|
|
||||||
|
# Idle before closing — human would pause
|
||||||
|
if random.random() < 0.5:
|
||||||
|
await page.wait_for_timeout(random.randint(3000, 10000))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
logger.error("Facebook scrape failed: %s", e)
|
logger.error("Facebook scrape failed: %s", e)
|
||||||
return []
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
||||||
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for p in all_posts:
|
|
||||||
key = p.get('content', '')[:100]
|
|
||||||
if key not in seen:
|
|
||||||
seen.add(key)
|
|
||||||
deduped.append(p)
|
|
||||||
return deduped[:20]
|
|
||||||
|
|
||||||
async def ask_ollama(prompt: str) -> str:
|
async def ask_ollama(prompt: str) -> str:
|
||||||
|
import httpx
|
||||||
async with httpx.AsyncClient(timeout=120) as c:
|
async with httpx.AsyncClient(timeout=120) as c:
|
||||||
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
||||||
"model": CLASSIFY_MODEL,
|
"model": CLASSIFY_MODEL,
|
||||||
@@ -262,42 +365,6 @@ async def ask_ollama(prompt: str) -> str:
|
|||||||
data = r.json()
|
data = r.json()
|
||||||
return data["message"]["content"]
|
return data["message"]["content"]
|
||||||
|
|
||||||
async def scrape_warriorforum() -> list[dict]:
|
|
||||||
results = []
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(headers=HEADERS, timeout=15, follow_redirects=True) as c:
|
|
||||||
r = await c.get('https://www.warriorforum.com/wanted-members-looking-hire-you/')
|
|
||||||
soup = BeautifulSoup(r.text, 'lxml')
|
|
||||||
for row in soup.find_all('td', class_='FlexTable-item--title'):
|
|
||||||
a = row.find('a', href=True)
|
|
||||||
if not a:
|
|
||||||
continue
|
|
||||||
href = a['href']
|
|
||||||
title = a.text.strip()
|
|
||||||
if not title or len(title) < 15 or not kw_match_strict(title):
|
|
||||||
continue
|
|
||||||
author_div = row.find('div', class_='FlexTable-item-author')
|
|
||||||
author = author_div.get_text(strip=True).lstrip('by') if author_div else ''
|
|
||||||
date_str = ''
|
|
||||||
date_div = row.find('div', class_=lambda c: c and 'media--available' in c)
|
|
||||||
if date_div:
|
|
||||||
txt = date_div.get_text(strip=True)
|
|
||||||
m = re.search(r'(\d{10})', txt)
|
|
||||||
if m:
|
|
||||||
date_str = datetime.utcfromtimestamp(int(m.group(1))).strftime('%Y-%m-%d')
|
|
||||||
if not href.startswith('http'):
|
|
||||||
href = 'https://www.warriorforum.com' + href
|
|
||||||
results.append({
|
|
||||||
"title": title, "url": href,
|
|
||||||
"author": author,
|
|
||||||
"content": title[:300],
|
|
||||||
"source": "warriorforum",
|
|
||||||
"date": date_str
|
|
||||||
})
|
|
||||||
except Exception:
|
|
||||||
logger.error("WarriorForum scrape failed", exc_info=True)
|
|
||||||
return results
|
|
||||||
|
|
||||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||||
if not results:
|
if not results:
|
||||||
return []
|
return []
|
||||||
@@ -320,13 +387,10 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
try:
|
try:
|
||||||
raw = await ask_ollama(prompt)
|
raw = await ask_ollama(prompt)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
# Strip markdown code fences
|
|
||||||
if raw.startswith("```"):
|
if raw.startswith("```"):
|
||||||
# Remove opening fence
|
|
||||||
first_nl = raw.find('\n')
|
first_nl = raw.find('\n')
|
||||||
if first_nl != -1:
|
if first_nl != -1:
|
||||||
raw = raw[first_nl + 1:]
|
raw = raw[first_nl + 1:]
|
||||||
# Remove closing fence
|
|
||||||
if raw.endswith("```"):
|
if raw.endswith("```"):
|
||||||
raw = raw[:-3]
|
raw = raw[:-3]
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
@@ -339,18 +403,25 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
filtered.append(results[i])
|
filtered.append(results[i])
|
||||||
if filtered:
|
if filtered:
|
||||||
return filtered[:10]
|
return filtered[:10]
|
||||||
# AI successfully classified but returned all "no" — respect that decision
|
|
||||||
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
||||||
return []
|
return []
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
||||||
|
|
||||||
# Fallback: only use keyword filter when AI failed
|
|
||||||
if not ai_succeeded:
|
if not ai_succeeded:
|
||||||
|
strict_keywords = [
|
||||||
|
"website", "web design", "web develop", "web dev",
|
||||||
|
"build my website", "build a website", "create a website",
|
||||||
|
"need web", "looking for web", "new website",
|
||||||
|
"landing page", "wordpress",
|
||||||
|
"need a website", "my website", "business website",
|
||||||
|
"need a designer", "help with my website",
|
||||||
|
"redesign", "update my website",
|
||||||
|
]
|
||||||
filtered = []
|
filtered = []
|
||||||
for r in results:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
if not kw_match_strict(t):
|
if not any(kw in t for kw in strict_keywords):
|
||||||
continue
|
continue
|
||||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
||||||
'affordable web', 'web hosting', 'free website',
|
'affordable web', 'web hosting', 'free website',
|
||||||
@@ -365,46 +436,10 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
class ScrapeRequest(BaseModel):
|
|
||||||
query: str = ""
|
|
||||||
|
|
||||||
@app.post("/scrape/requests")
|
|
||||||
async def scrape_requests(req: ScrapeRequest):
|
|
||||||
global last_scrape_time
|
|
||||||
now = datetime.now()
|
|
||||||
if last_scrape_time and (now - last_scrape_time) < timedelta(seconds=15):
|
|
||||||
return {"error": "rate_limited", "retry_after": 15}
|
|
||||||
last_scrape_time = now
|
|
||||||
results = await scrape_warriorforum()
|
|
||||||
if results:
|
|
||||||
results = await classify_leads(results)
|
|
||||||
return results[:10]
|
|
||||||
|
|
||||||
@app.post("/scrape/facebook")
|
@app.post("/scrape/facebook")
|
||||||
async def scrape_facebook_endpoint():
|
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
||||||
results = await scrape_facebook()
|
result = await scrape_facebook(profile_path, force)
|
||||||
if results:
|
return result
|
||||||
results = await classify_leads(results)
|
|
||||||
return results[:15]
|
|
||||||
|
|
||||||
@app.post("/scrape/all")
|
|
||||||
async def scrape_all():
|
|
||||||
results = []
|
|
||||||
try:
|
|
||||||
wf = await scrape_warriorforum()
|
|
||||||
if wf:
|
|
||||||
wf = await classify_leads(wf)
|
|
||||||
results.extend(wf[:5])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("WarriorForum scrape in /scrape/all failed: %s", e)
|
|
||||||
try:
|
|
||||||
fb = await scrape_facebook()
|
|
||||||
if fb:
|
|
||||||
fb = await classify_leads(fb)
|
|
||||||
results.extend(fb[:8])
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Facebook scrape in /scrape/all failed: %s", e)
|
|
||||||
return results[:12]
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
python : INFO: Started server process [20044]
|
|
||||||
+ CategoryInfo : NotSpecified: (INFO: Started server process [20044]:String) [], RemoteException
|
|
||||||
+ FullyQualifiedErrorId : NativeCommandError
|
|
||||||
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
[2026-06-22T19:24:48.030482] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:27:19.231701] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:28:28.335765] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:29:05.796265] Browser Use service starting on port 3008
|
|
||||||
[2026-06-22T19:29:17.042807] Scraping WarriorForum...
|
|
||||||
[2026-06-22T19:29:19.075166] WarriorForum: 55 results
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
INFO: Started server process [24268]
|
|
||||||
INFO: Waiting for application startup.
|
|
||||||
INFO: Application startup complete.
|
|
||||||
INFO: Uvicorn running on http://0.0.0.0:3008 (Press CTRL+C to quit)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
Browser Use service starting on port 3008
|
|
||||||
INFO: 127.0.0.1:65003 - "GET /health HTTP/1.1" 200 OK
|
|
||||||
Scraping WarriorForum...
|
|
||||||
WarriorForum: 55 results
|
|
||||||
INFO: 127.0.0.1:54587 - "POST /scrape/all HTTP/1.1" 200 OK
|
|
||||||
@@ -126,10 +126,10 @@ separate 1:N child tables.
|
|||||||
|
|
||||||
| Username | Password | Role |
|
| Username | Password | Role |
|
||||||
|----------|----------|------|
|
|----------|----------|------|
|
||||||
| `superadmin_demo` | `SuperAdmin@2026` | SUPER_ADMIN |
|
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
|
||||||
| `admin_demo` | `AdminAccess@2026` | ADMIN |
|
| `admin_demo` | `[REDACTED]` | ADMIN |
|
||||||
| `sales_demo` | `SalesAccess@2026` | SALES_USER |
|
| `sales_demo` | `[REDACTED]` | SALES_USER |
|
||||||
| `dev_demo` | `DevTesting@2026` | DEVELOPER |
|
| `dev_demo` | `[REDACTED]` | DEVELOPER |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
|
||||||
|
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS facebook_accounts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
label VARCHAR(100) NOT NULL,
|
||||||
|
profile_path TEXT NOT NULL,
|
||||||
|
cookie_file TEXT NOT NULL,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
last_scrape_at TIMESTAMPTZ,
|
||||||
|
last_success_at TIMESTAMPTZ,
|
||||||
|
last_error_at TIMESTAMPTZ,
|
||||||
|
last_error_message TEXT,
|
||||||
|
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||||
|
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
flagged_at TIMESTAMPTZ,
|
||||||
|
flagged_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
leads_found INT NOT NULL DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
detected_flag VARCHAR(50),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
|
||||||
Generated
+339
-216
@@ -34,7 +34,7 @@
|
|||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "^15.5.19",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.5.19",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
@@ -360,10 +360,20 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/colour": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
|
||||||
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -379,13 +389,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
"@img/sharp-libvips-darwin-arm64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
|
||||||
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -401,13 +411,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
"@img/sharp-libvips-darwin-x64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
|
||||||
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -421,9 +431,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
|
||||||
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -437,12 +447,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.0.5",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
|
||||||
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -453,12 +466,53 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
|
||||||
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -469,12 +523,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
|
||||||
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -485,12 +542,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
|
||||||
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -501,12 +561,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
|
||||||
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -517,12 +580,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.0.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
|
||||||
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -533,12 +599,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
|
||||||
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -551,16 +620,19 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.0.5"
|
"@img/sharp-libvips-linux-arm": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
|
||||||
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -573,16 +645,69 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
"@img/sharp-libvips-linux-arm64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-ppc64": "1.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-riscv64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
|
||||||
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -595,16 +720,19 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
"@img/sharp-libvips-linux-s390x": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
|
||||||
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -617,16 +745,19 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.0.4"
|
"@img/sharp-libvips-linux-x64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
|
||||||
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -639,16 +770,19 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
|
||||||
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -661,20 +795,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
|
||||||
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emnapi/runtime": "^1.2.0"
|
"@emnapi/runtime": "^1.7.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
@@ -683,10 +817,29 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
|
"version": "0.34.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
|
||||||
|
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
|
||||||
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -703,12 +856,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
|
||||||
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -774,26 +928,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz",
|
||||||
"integrity": "sha512-WNRvtgnRVDD4oM8gbUcRc27IAhaL4eXQ/2ovGbgLnPGUvdyDr8UdXP4Q/IBDdAdojnD2eScryIDirv0YUCjUVw=="
|
"integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==",
|
||||||
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.19.tgz",
|
||||||
"integrity": "sha512-rbsF17XGzHtR7SDWzWpavSfum3/UdnF8bAaisnKwP//si3KWPTedVUsflAdjyK1zW3rweBjbALfKcavFneLGvg==",
|
"integrity": "sha512-Ctwb4qYuMbHN/1oXLlTdMchwG8h8Xzwq+wGZZMgF3o6+uwyBKAI2c96bdOsl+C62PaUD0Jkh+QpNkhUeDlam0Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-glob": "3.3.1"
|
"fast-glob": "3.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz",
|
||||||
"integrity": "sha512-QecQXPD0yRHxSXWL5Ff80nD+A56sUXZG9koUsjWJwA2Z0ZgVQfuy7gd0/otjxoOovPVHR2eVEvPMHbtZP+pf9w==",
|
"integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -803,12 +960,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz",
|
||||||
"integrity": "sha512-pb7Bye3y1Og3PlCtnz2oO4z+/b3pH2/HSYkLbL0hbVuTGil7fPen8/3pyyLjdiTLcFJ+ymeU3bck5hd4IPFFCA==",
|
"integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
@@ -818,12 +976,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz",
|
||||||
"integrity": "sha512-12oSaBFjGpB227VHzoXF3gJoK2SlVGmFJMaBJSu5rbpaoT5OjP5OuCLuR9/jnyBF1BAWMs/boa6mLMoJPRriMA==",
|
"integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -833,12 +995,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz",
|
||||||
"integrity": "sha512-QARO88fR/a+wg+OFC3dGytJVVviiYFEyjc/Zzkjn/HevUuJ7qGUUAUYy5PGVWY1YgTzeRYz78akQrVQ8r+sMjw==",
|
"integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -848,12 +1014,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz",
|
||||||
"integrity": "sha512-Z50b0gvYiUU1vLzfAMiChV8Y+6u/T2mdfpXPHraqpypP7yIT2UV9YBBhcwYkxujmCvGEcRTVWOj3EP7XW/wUnw==",
|
"integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -863,12 +1033,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz",
|
||||||
"integrity": "sha512-7H9C4FAsrTAbA/ENzvFWsVytqRYhaJYKa2B3fyQcv96TkOGVMcvyS6s+sj4jZlacxxTcn7ygaMXUPkEk7b78zw==",
|
"integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"linux"
|
"linux"
|
||||||
@@ -878,12 +1052,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz",
|
||||||
"integrity": "sha512-Z/v3WV5xRaeWlgJzN9r4PydWD8sXV35ywc28W63i37G2jnUgScA4OOgS8hQdiXLxE3gqfSuHTicUhr7931OXPQ==",
|
"integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -893,12 +1068,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz",
|
||||||
"integrity": "sha512-NGLchGruagh8lQpDr98bHLyWJXOBSmkEAfK980OiNBa7vNm6PsNoPvzTfstT78WyOeMRQphEQ455rggd7Eo+Dw==",
|
"integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"win32"
|
"win32"
|
||||||
@@ -1879,17 +2055,13 @@
|
|||||||
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@swc/counter": {
|
|
||||||
"version": "0.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
|
|
||||||
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="
|
|
||||||
},
|
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.13",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
"integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==",
|
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.4.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-table": {
|
"node_modules/@tanstack/react-table": {
|
||||||
@@ -3050,17 +3222,6 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/busboy": {
|
|
||||||
"version": "1.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
|
||||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
|
||||||
"dependencies": {
|
|
||||||
"streamsearch": "^1.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.16.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind": {
|
"node_modules/call-bind": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||||
@@ -3236,24 +3397,11 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/color": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"color-convert": "^2.0.1",
|
|
||||||
"color-string": "^1.9.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.5.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
},
|
},
|
||||||
@@ -3265,17 +3413,7 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true
|
"dev": true
|
||||||
},
|
|
||||||
"node_modules/color-string": {
|
|
||||||
"version": "1.9.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
|
||||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"color-name": "^1.0.0",
|
|
||||||
"simple-swizzle": "^0.2.2"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
@@ -3606,6 +3744,7 @@
|
|||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -3929,12 +4068,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.19.tgz",
|
||||||
"integrity": "sha512-97mLaAhbJKVQYXUBBrenRtEUAA6bNDPxWfaFEd6mEhKfpajP4wJrW4l7BUlHuYWxR8oQa9W014qBJpumpJQwWA==",
|
"integrity": "sha512-UZwkuhBCNxVZfo93MSHRDOVNWXooJJGcAUyTAVIp0+9QFhH4SqJxWY0s6Mk9C2kMi777HPMn3dseOrZshWpG9Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "15.0.4",
|
"@next/eslint-plugin-next": "15.5.19",
|
||||||
"@rushstack/eslint-patch": "^1.10.3",
|
"@rushstack/eslint-patch": "^1.10.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||||
@@ -3942,7 +4082,7 @@
|
|||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.31.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
"eslint-plugin-react": "^7.35.0",
|
"eslint-plugin-react": "^7.37.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0"
|
"eslint-plugin-react-hooks": "^5.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -4279,6 +4419,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||||
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
"integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodelib/fs.stat": "^2.0.2",
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
"@nodelib/fs.walk": "^1.2.3",
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
@@ -4295,6 +4436,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-glob": "^4.0.1"
|
"is-glob": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -4808,12 +4950,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arrayish": {
|
|
||||||
"version": "0.3.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
|
||||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/is-async-function": {
|
"node_modules/is-async-function": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz",
|
||||||
@@ -5543,15 +5679,13 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "15.0.4",
|
"version": "15.5.19",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-15.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz",
|
||||||
"integrity": "sha512-nuy8FH6M1FG0lktGotamQDCXhh5hZ19Vo0ht1AOIQWrYJLP598TIUagKtvJrfJ5AGwB/WmDqkKaKhMpVifvGPA==",
|
"integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "15.0.4",
|
"@next/env": "15.5.19",
|
||||||
"@swc/counter": "0.1.3",
|
"@swc/helpers": "0.5.15",
|
||||||
"@swc/helpers": "0.5.13",
|
|
||||||
"busboy": "1.6.0",
|
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
"postcss": "8.4.31",
|
"postcss": "8.4.31",
|
||||||
"styled-jsx": "5.1.6"
|
"styled-jsx": "5.1.6"
|
||||||
@@ -5563,22 +5697,22 @@
|
|||||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "15.0.4",
|
"@next/swc-darwin-arm64": "15.5.19",
|
||||||
"@next/swc-darwin-x64": "15.0.4",
|
"@next/swc-darwin-x64": "15.5.19",
|
||||||
"@next/swc-linux-arm64-gnu": "15.0.4",
|
"@next/swc-linux-arm64-gnu": "15.5.19",
|
||||||
"@next/swc-linux-arm64-musl": "15.0.4",
|
"@next/swc-linux-arm64-musl": "15.5.19",
|
||||||
"@next/swc-linux-x64-gnu": "15.0.4",
|
"@next/swc-linux-x64-gnu": "15.5.19",
|
||||||
"@next/swc-linux-x64-musl": "15.0.4",
|
"@next/swc-linux-x64-musl": "15.5.19",
|
||||||
"@next/swc-win32-arm64-msvc": "15.0.4",
|
"@next/swc-win32-arm64-msvc": "15.5.19",
|
||||||
"@next/swc-win32-x64-msvc": "15.0.4",
|
"@next/swc-win32-x64-msvc": "15.5.19",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentelemetry/api": "^1.1.0",
|
"@opentelemetry/api": "^1.1.0",
|
||||||
"@playwright/test": "^1.41.2",
|
"@playwright/test": "^1.51.1",
|
||||||
"babel-plugin-react-compiler": "*",
|
"babel-plugin-react-compiler": "*",
|
||||||
"react": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0",
|
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||||
"react-dom": "^18.2.0 || 19.0.0-rc-66855b96-20241106 || ^19.0.0",
|
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
|
||||||
"sass": "^1.3.0"
|
"sass": "^1.3.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
@@ -6722,15 +6856,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.33.5",
|
"version": "0.34.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||||
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color": "^4.2.3",
|
"@img/colour": "^1.0.0",
|
||||||
"detect-libc": "^2.0.3",
|
"detect-libc": "^2.1.2",
|
||||||
"semver": "^7.6.3"
|
"semver": "^7.7.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
@@ -6739,25 +6874,30 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.33.5",
|
"@img/sharp-darwin-arm64": "0.34.5",
|
||||||
"@img/sharp-darwin-x64": "0.33.5",
|
"@img/sharp-darwin-x64": "0.34.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
"@img/sharp-libvips-darwin-arm64": "1.2.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
"@img/sharp-libvips-darwin-x64": "1.2.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.0.5",
|
"@img/sharp-libvips-linux-arm": "1.2.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
"@img/sharp-libvips-linux-arm64": "1.2.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
"@img/sharp-libvips-linux-ppc64": "1.2.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.0.4",
|
"@img/sharp-libvips-linux-riscv64": "1.2.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
"@img/sharp-libvips-linux-s390x": "1.2.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
"@img/sharp-libvips-linux-x64": "1.2.4",
|
||||||
"@img/sharp-linux-arm": "0.33.5",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
|
||||||
"@img/sharp-linux-arm64": "0.33.5",
|
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
|
||||||
"@img/sharp-linux-s390x": "0.33.5",
|
"@img/sharp-linux-arm": "0.34.5",
|
||||||
"@img/sharp-linux-x64": "0.33.5",
|
"@img/sharp-linux-arm64": "0.34.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
"@img/sharp-linux-ppc64": "0.34.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.33.5",
|
"@img/sharp-linux-riscv64": "0.34.5",
|
||||||
"@img/sharp-wasm32": "0.33.5",
|
"@img/sharp-linux-s390x": "0.34.5",
|
||||||
"@img/sharp-win32-ia32": "0.33.5",
|
"@img/sharp-linux-x64": "0.34.5",
|
||||||
"@img/sharp-win32-x64": "0.33.5"
|
"@img/sharp-linuxmusl-arm64": "0.34.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.34.5",
|
||||||
|
"@img/sharp-wasm32": "0.34.5",
|
||||||
|
"@img/sharp-win32-arm64": "0.34.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.34.5",
|
||||||
|
"@img/sharp-win32-x64": "0.34.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
@@ -6866,15 +7006,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/simple-swizzle": {
|
|
||||||
"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==",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"is-arrayish": "^0.3.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/sonner": {
|
"node_modules/sonner": {
|
||||||
"version": "1.7.4",
|
"version": "1.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||||
@@ -6919,14 +7050,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/streamsearch": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@
|
|||||||
"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:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||||
"dev:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||||
"dev:ollama": "powershell -NoProfile -Command \"if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||||
"dev:rust": "cd rust-ai && cargo run",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
"dev:browser-use": "cd browser-use-service && python main.py",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[target.x86_64-pc-windows-gnu]
|
||||||
|
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
|
||||||
+214
-50
@@ -15,6 +15,7 @@ use tokio::sync::Mutex;
|
|||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use rand::Rng;
|
use rand::Rng;
|
||||||
|
use chrono::Timelike;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -93,6 +94,25 @@ struct LeadStore {
|
|||||||
max_size: usize,
|
max_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ScrapeResponse {
|
||||||
|
success: bool,
|
||||||
|
leads: Vec<ScrapeLead>,
|
||||||
|
flagged: bool,
|
||||||
|
flag_reason: Option<String>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
|
struct ScrapeLead {
|
||||||
|
title: String,
|
||||||
|
url: String,
|
||||||
|
author: String,
|
||||||
|
date: String,
|
||||||
|
content: String,
|
||||||
|
source: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
impl LeadStore {
|
impl LeadStore {
|
||||||
fn new(max_size: usize) -> Self {
|
fn new(max_size: usize) -> Self {
|
||||||
Self { leads: Vec::new(), max_size }
|
Self { leads: Vec::new(), max_size }
|
||||||
@@ -188,7 +208,7 @@ fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (Stat
|
|||||||
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||||
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||||
})?;
|
})?;
|
||||||
match claims.role.as_str() {
|
match claims.role.to_lowercase().as_str() {
|
||||||
"sales" | "admin" | "super_admin" => Ok(claims),
|
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||||
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||||
}
|
}
|
||||||
@@ -266,30 +286,64 @@ async fn handle_chat(
|
|||||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||||
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
let service_url = "http://localhost:3008/scrape/all".to_string();
|
let base_url = "http://localhost:3008/scrape/facebook";
|
||||||
if let Ok(resp) = state.http_client
|
use std::fmt::Write;
|
||||||
.post(&service_url)
|
let mut service_url = base_url.to_string();
|
||||||
.timeout(Duration::from_secs(120))
|
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||||
.send()
|
"SELECT id, profile_path FROM facebook_accounts \
|
||||||
.await
|
WHERE is_active = TRUE AND flagged = FALSE \
|
||||||
|
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||||
|
)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
{
|
{
|
||||||
if let Ok(leads_data) = resp.json::<Vec<serde_json::Value>>().await {
|
let encoded: String = path.chars().map(|c| match c {
|
||||||
info!("Scraped {} leads from {}", leads_data.len(), service_url);
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
|
||||||
let mut store = state.leads.lock().await;
|
_ => format!("%{:02X}", c as u8),
|
||||||
for item in &leads_data {
|
}).collect();
|
||||||
store.push(Lead {
|
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
|
||||||
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
|
||||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
} else {
|
||||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
warn!("No active Facebook account found for on-demand scrape");
|
||||||
found_at: now,
|
}
|
||||||
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
let req_builder = state.http_client.post(&service_url);
|
||||||
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
|
||||||
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
match req_builder.send().await {
|
||||||
});
|
Ok(resp) => {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
info!("Python scrape response ({}): {} bytes", status, body.len());
|
||||||
|
if body.starts_with('{') {
|
||||||
|
match serde_json::from_str::<ScrapeResponse>(&body) {
|
||||||
|
Ok(scrape_resp) => {
|
||||||
|
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
|
||||||
|
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
|
||||||
|
warn!("Python returned error: {:?}", scrape_resp.error);
|
||||||
|
}
|
||||||
|
let mut store = state.leads.lock().await;
|
||||||
|
for item in &scrape_resp.leads {
|
||||||
|
store.push(Lead {
|
||||||
|
title: truncate(&item.title, 120),
|
||||||
|
url: item.url.clone(),
|
||||||
|
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||||
|
found_at: now,
|
||||||
|
author: truncate(&item.author, 60),
|
||||||
|
date: truncate(&item.date, 30),
|
||||||
|
content: truncate(&item.content, 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
Err(e) => {
|
||||||
error!("Scraper service unreachable: {}", service_url);
|
error!("Scraper request error: {} - URL: {}", e, base_url);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||||
@@ -411,7 +465,7 @@ async fn main() {
|
|||||||
info!("Connected to PostgreSQL");
|
info!("Connected to PostgreSQL");
|
||||||
|
|
||||||
let http_client = reqwest::Client::builder()
|
let http_client = reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(120))
|
.timeout(Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to build HTTP client");
|
.expect("Failed to build HTTP client");
|
||||||
|
|
||||||
@@ -441,7 +495,7 @@ async fn main() {
|
|||||||
.route("/ai/jobs", get(handle_jobs))
|
.route("/ai/jobs", get(handle_jobs))
|
||||||
.route("/health", get(handle_health))
|
.route("/health", get(handle_health))
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state.clone());
|
||||||
|
|
||||||
let addr = format!("{}:{}", host, port);
|
let addr = format!("{}:{}", host, port);
|
||||||
info!("CRM AI server listening on {}", addr);
|
info!("CRM AI server listening on {}", addr);
|
||||||
@@ -451,10 +505,11 @@ async fn main() {
|
|||||||
.expect("Failed to bind address");
|
.expect("Failed to bind address");
|
||||||
|
|
||||||
let bg_leads = lead_store.clone();
|
let bg_leads = lead_store.clone();
|
||||||
let bg_url = "http://localhost:3008/scrape/all".to_string();
|
let bg_db = state.db.clone();
|
||||||
|
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(120))
|
.timeout(Duration::from_secs(300))
|
||||||
.build()
|
.build()
|
||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -463,40 +518,149 @@ async fn main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Initial delay to let user on-demand requests take priority
|
||||||
|
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
// 10% random cycle skip — makes pattern non-periodic
|
||||||
|
if rand::thread_rng().gen_range(0..100) < 10 {
|
||||||
|
info!("Skipping this scrape cycle (random 10% skip)");
|
||||||
|
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip night hours (23:00 – 06:00)
|
||||||
|
let hour = chrono::Local::now().hour();
|
||||||
|
if hour < 6 || hour >= 23 {
|
||||||
|
info!("Night hours ({}) — skipping scrape", hour);
|
||||||
|
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||||
match client.post(&bg_url).send().await {
|
|
||||||
Ok(resp) => {
|
// Pick next active un-flagged account
|
||||||
if resp.status().is_success() {
|
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||||
match resp.json::<Vec<serde_json::Value>>().await {
|
"SELECT id, profile_path FROM facebook_accounts \
|
||||||
Ok(data) => {
|
WHERE is_active = TRUE AND flagged = FALSE \
|
||||||
let mut store = bg_leads.lock().await;
|
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||||
for item in &data {
|
)
|
||||||
store.push(Lead {
|
.fetch_optional(&bg_db)
|
||||||
title: truncate(item["title"].as_str().unwrap_or(""), 120),
|
.await;
|
||||||
url: item["url"].as_str().unwrap_or("").to_string(),
|
|
||||||
source: item["source"].as_str().unwrap_or("unknown").to_string(),
|
match account {
|
||||||
found_at: now,
|
Ok(Some((account_id, profile_path))) => {
|
||||||
author: truncate(item["author"].as_str().unwrap_or(""), 60),
|
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
|
||||||
date: truncate(item["date"].as_str().unwrap_or(""), 30),
|
Ok(resp) => {
|
||||||
content: truncate(item["content"].as_str().unwrap_or(""), 300),
|
if resp.status().is_success() {
|
||||||
});
|
match resp.json::<ScrapeResponse>().await {
|
||||||
|
Ok(data) => {
|
||||||
|
let leads_count = data.leads.len() as i32;
|
||||||
|
if data.flagged {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
|
||||||
|
flagged_reason = $2, last_error_at = NOW(), \
|
||||||
|
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
|
||||||
|
WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&data.flag_reason)
|
||||||
|
.bind(&data.error)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
|
||||||
|
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO notifications (user_id, type, title, description, link) \
|
||||||
|
SELECT id, 'warning', 'Facebook Account Flagged', \
|
||||||
|
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
|
||||||
|
NULL \
|
||||||
|
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
|
||||||
|
JOIN roles r ON r.id = ur.role_id \
|
||||||
|
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
|
||||||
|
)
|
||||||
|
.bind(&account_id.to_string())
|
||||||
|
.bind(reason)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
} else if data.success {
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
|
||||||
|
last_success_at = NOW(), consecutive_failures = 0, \
|
||||||
|
updated_at = NOW() WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut store = bg_leads.lock().await;
|
||||||
|
for item in &data.leads {
|
||||||
|
store.push(Lead {
|
||||||
|
title: truncate(&item.title, 120),
|
||||||
|
url: item.url.clone(),
|
||||||
|
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||||
|
found_at: now,
|
||||||
|
author: truncate(&item.author, 60),
|
||||||
|
date: truncate(&item.date, 30),
|
||||||
|
content: truncate(&item.content, 300),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
|
||||||
|
} else {
|
||||||
|
// Increment failures; auto-flag if >= 3 consecutive
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE facebook_accounts SET last_error_at = NOW(), \
|
||||||
|
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
|
||||||
|
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
|
||||||
|
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
|
||||||
|
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
|
||||||
|
updated_at = NOW() WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(&data.error)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO facebook_scrape_logs \
|
||||||
|
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
|
||||||
|
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
|
||||||
|
)
|
||||||
|
.bind(account_id)
|
||||||
|
.bind(data.success && !data.flagged)
|
||||||
|
.bind(leads_count)
|
||||||
|
.bind(&data.error)
|
||||||
|
.bind(&data.flag_reason)
|
||||||
|
.execute(&bg_db)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to parse scraper JSON: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
Err(e) => {
|
warn!("Scraper returned status: {}", resp.status());
|
||||||
warn!("Failed to parse scraper JSON: {}", e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
Err(e) => {
|
||||||
warn!("Scraper returned status: {}", resp.status());
|
warn!("Scraper request failed: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(None) => {
|
||||||
|
info!("No active Facebook accounts available — skipping scrape cycle");
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("Scraper request failed: {}", e);
|
warn!("Failed to query Facebook accounts: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let delay = rand::thread_rng().gen_range(120..300);
|
|
||||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h – 5.5h
|
||||||
|
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,15 @@ export default function DashboardPage() {
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 relative">
|
||||||
<PageHeader title="Dashboard" description="Overview of your sales pipeline">
|
{/* Daily Bugle watermark */}
|
||||||
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
||||||
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
|
DAILY BUGLE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
@@ -78,7 +85,7 @@ export default function DashboardPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Pipeline Overview</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{stats
|
{stats
|
||||||
@@ -89,7 +96,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-muted-foreground">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
|
|||||||
@@ -116,13 +116,28 @@ export async function POST(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const msg = result.rows[0]
|
const msg = result.rows[0]
|
||||||
|
const senderName = `${user.firstName} ${user.lastName}`
|
||||||
|
|
||||||
|
const otherResult = await query(
|
||||||
|
`SELECT user_id FROM conversation_participants
|
||||||
|
WHERE conversation_id = $1 AND user_id != $2`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const row of otherResult.rows) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||||
|
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
|
||||||
|
[row.user_id, `${senderName} sent a message`, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
message: {
|
message: {
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
conversationId: id,
|
conversationId: id,
|
||||||
senderId: user.id,
|
senderId: user.id,
|
||||||
senderName: `${user.firstName} ${user.lastName}`,
|
senderName,
|
||||||
senderAvatar: user.avatar,
|
senderAvatar: user.avatar,
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
timestamp: formatTime(new Date(msg.created_at)),
|
timestamp: formatTime(new Date(msg.created_at)),
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ export async function POST(
|
|||||||
[id, user.id],
|
[id, user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE notifications SET is_read = TRUE
|
||||||
|
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
||||||
|
[user.id, id],
|
||||||
|
)
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Mark read error:", error)
|
console.error("Mark read error:", error)
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ function stageToStatus(name: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLeadsInRange(start: Date, end: Date) {
|
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||||
l.notes, l.assigned_to, l.score,
|
l.notes, l.assigned_to, l.score,
|
||||||
@@ -59,8 +59,11 @@ async function fetchLeadsInRange(start: Date, end: Date) {
|
|||||||
LEFT JOIN users u ON u.id = l.assigned_to
|
LEFT JOIN users u ON u.id = l.assigned_to
|
||||||
WHERE l.deleted_at IS NULL
|
WHERE l.deleted_at IS NULL
|
||||||
AND l.created_at >= $1 AND l.created_at <= $2
|
AND l.created_at >= $1 AND l.created_at <= $2
|
||||||
|
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
||||||
ORDER BY l.created_at DESC`,
|
ORDER BY l.created_at DESC`,
|
||||||
[start.toISOString(), end.toISOString()]
|
isAdmin
|
||||||
|
? [start.toISOString(), end.toISOString()]
|
||||||
|
: [start.toISOString(), end.toISOString(), userId]
|
||||||
)
|
)
|
||||||
return result.rows.map((r: any) => ({
|
return result.rows.map((r: any) => ({
|
||||||
...r,
|
...r,
|
||||||
@@ -118,6 +121,8 @@ export async function GET(request: NextRequest) {
|
|||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const period = searchParams.get("period") || "6months"
|
const period = searchParams.get("period") || "6months"
|
||||||
const yearParam = searchParams.get("year")
|
const yearParam = searchParams.get("year")
|
||||||
@@ -134,8 +139,8 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
const [currentLeads, prevLeads] = await Promise.all([
|
||||||
fetchLeadsInRange(start, end),
|
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end),
|
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||||
])
|
])
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
const currentCounts = countStatuses(currentLeads)
|
||||||
|
|||||||
@@ -3,12 +3,28 @@ import { getSessionUser } from "@/lib/auth"
|
|||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
|
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
||||||
|
const result = await query(
|
||||||
|
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
AND (assigned_to = $2 OR EXISTS (
|
||||||
|
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||||
|
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
|
||||||
|
))`,
|
||||||
|
[leadId, userId]
|
||||||
|
)
|
||||||
|
return result.rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
if (!await checkLeadAccess(id, user.id)) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
const { content } = await request.json()
|
const { content } = await request.json()
|
||||||
if (!content?.trim()) {
|
if (!content?.trim()) {
|
||||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||||
@@ -33,6 +49,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
|
if (!await checkLeadAccess(id, user.id)) {
|
||||||
|
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||||
|
}
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
|||||||
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
if (body.description !== undefined) { fields.push(`notes = $${idx++}`); values.push(body.description) }
|
||||||
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
if (body.source !== undefined) { fields.push(`source_id = $${idx++}`); values.push(body.source) }
|
||||||
if (body.assignedUserId !== undefined) {
|
if (body.assignedUserId !== undefined) {
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
if (!isAdmin) {
|
||||||
|
// non-admin cannot reassign
|
||||||
|
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
|
||||||
|
}
|
||||||
fields.push(`assigned_to = $${idx++}`)
|
fields.push(`assigned_to = $${idx++}`)
|
||||||
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,15 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
|
||||||
|
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
|
||||||
|
let assignedUserId = body.assignedUserId
|
||||||
|
if (!isAdmin) {
|
||||||
|
assignedUserId = user.id
|
||||||
|
} else if (assignedUserId === "none" || !assignedUserId) {
|
||||||
|
assignedUserId = null
|
||||||
|
}
|
||||||
|
|
||||||
const stageResult = await query(
|
const stageResult = await query(
|
||||||
"SELECT id FROM lead_stages WHERE name = $1",
|
"SELECT id FROM lead_stages WHERE name = $1",
|
||||||
@@ -142,7 +151,7 @@ export async function POST(request: NextRequest) {
|
|||||||
body.description || null,
|
body.description || null,
|
||||||
body.source || null,
|
body.source || null,
|
||||||
stageId,
|
stageId,
|
||||||
body.assignedUserId === "none" ? null : body.assignedUserId,
|
assignedUserId,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export async function GET() {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT id, type, title, description, link, is_read, created_at
|
`SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
|
||||||
FROM notifications
|
FROM notifications
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
@@ -24,6 +24,8 @@ export async function GET() {
|
|||||||
link: r.link,
|
link: r.link,
|
||||||
read: r.is_read,
|
read: r.is_read,
|
||||||
timestamp: r.created_at,
|
timestamp: r.created_at,
|
||||||
|
contextId: r.context_id,
|
||||||
|
contextType: r.context_type,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const unreadResult = await query(
|
const unreadResult = await query(
|
||||||
@@ -47,7 +49,8 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const { type, title, description, link, userId } = await request.json()
|
const { type, title, description, link, userId } = await request.json()
|
||||||
const targetUserId = userId || user.id
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
const targetUserId = (userId && isAdmin) ? userId : user.id
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO notifications (user_id, type, title, description, link)
|
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const fields: string[] = []
|
||||||
|
const values: any[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (body.isActive !== undefined) {
|
||||||
|
fields.push(`is_active = $${idx++}`)
|
||||||
|
values.push(body.isActive)
|
||||||
|
}
|
||||||
|
if (body.label !== undefined) {
|
||||||
|
fields.push(`label = $${idx++}`)
|
||||||
|
values.push(body.label.trim())
|
||||||
|
}
|
||||||
|
if (body.profilePath !== undefined) {
|
||||||
|
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
||||||
|
values.push(body.profilePath.trim())
|
||||||
|
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if (body.unflag === true) {
|
||||||
|
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push(`updated_at = NOW()`)
|
||||||
|
values.push(id)
|
||||||
|
|
||||||
|
await query(
|
||||||
|
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
|
||||||
|
values
|
||||||
|
)
|
||||||
|
|
||||||
|
const updated = await query(
|
||||||
|
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
|
||||||
|
consecutive_failures, updated_at
|
||||||
|
FROM facebook_accounts WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(updated.rows[0] || { success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const accounts = await query(
|
||||||
|
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
||||||
|
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
|
||||||
|
fa.last_error_message, fa.consecutive_failures,
|
||||||
|
fa.flagged, fa.flagged_at, fa.flagged_reason,
|
||||||
|
fa.created_at, fa.updated_at,
|
||||||
|
COALESCE(sl.leads_found, 0) AS last_leads_found,
|
||||||
|
sl.success AS last_success,
|
||||||
|
sl.detected_flag AS last_detected_flag
|
||||||
|
FROM facebook_accounts fa
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT leads_found, success, detected_flag
|
||||||
|
FROM facebook_scrape_logs
|
||||||
|
WHERE account_id = fa.id
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) sl ON TRUE
|
||||||
|
ORDER BY fa.created_at ASC`
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(accounts.rows)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { label, profilePath } = await request.json()
|
||||||
|
if (!label?.trim() || !profilePath?.trim()) {
|
||||||
|
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
|
||||||
|
const result = await query(
|
||||||
|
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
RETURNING id, label, profile_path, is_active, created_at`,
|
||||||
|
[label.trim(), profilePath.trim(), cookieFile]
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json(result.rows[0], { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook accounts POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const accountId = searchParams.get("accountId")
|
||||||
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
|
|
||||||
|
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
|
||||||
|
sl.started_at, sl.completed_at, sl.success,
|
||||||
|
sl.leads_found, sl.error_message, sl.detected_flag,
|
||||||
|
sl.created_at
|
||||||
|
FROM facebook_scrape_logs sl
|
||||||
|
JOIN facebook_accounts fa ON fa.id = sl.account_id`
|
||||||
|
const params: any[] = []
|
||||||
|
let paramIdx = 1
|
||||||
|
|
||||||
|
if (accountId) {
|
||||||
|
sql += ` WHERE sl.account_id = $${paramIdx++}`
|
||||||
|
params.push(accountId)
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
|
||||||
|
params.push(limit, offset)
|
||||||
|
|
||||||
|
const result = await query(sql, params)
|
||||||
|
return NextResponse.json(result.rows)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Facebook scrape logs GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ let prevTime = Date.now()
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const elapsed = now - prevTime
|
const elapsed = now - prevTime
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
||||||
|
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -12,6 +15,18 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
||||||
|
if (!allowed) {
|
||||||
|
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approximate decoded size: base64 is ~4/3 of original
|
||||||
|
const base64Data = avatar.split(",")[1] || ""
|
||||||
|
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
||||||
|
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||||
[avatar, user.id],
|
[avatar, user.id],
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url)
|
const { searchParams } = new URL(request.url)
|
||||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export async function GET(request: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const currentUser = await getSessionUser()
|
const currentUser = await getSessionUser()
|
||||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
const q = request.nextUrl.searchParams.get("q") || ""
|
const q = request.nextUrl.searchParams.get("q") || ""
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -1,7 +1,14 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(400%); }
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 210 40% 98%;
|
--background: 210 40% 98%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
@@ -9,7 +16,7 @@
|
|||||||
--card-foreground: 222.2 84% 4.9%;
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
--primary: 221.2 83.2% 53.3%;
|
--primary: 0 100% 40%;
|
||||||
--primary-foreground: 210 40% 98%;
|
--primary-foreground: 210 40% 98%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary: 210 40% 96.1%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
@@ -21,15 +28,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 214.3 31.8% 91.4%;
|
--border: 214.3 31.8% 91.4%;
|
||||||
--input: 214.3 31.8% 91.4%;
|
--input: 214.3 31.8% 91.4%;
|
||||||
--ring: 221.2 83.2% 53.3%;
|
--ring: 0 100% 40%;
|
||||||
--sidebar: 0 0% 100%;
|
--sidebar: 0 0% 100%;
|
||||||
--sidebar-foreground: 222.2 84% 4.9%;
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
--sidebar-primary: 221.2 83.2% 53.3%;
|
--sidebar-primary: 0 100% 40%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 210 40% 96.1%;
|
--sidebar-accent: 210 40% 96.1%;
|
||||||
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
--sidebar-accent-foreground: 222.2 84% 4.9%;
|
||||||
--sidebar-border: 214.3 31.8% 91.4%;
|
--sidebar-border: 214.3 31.8% 91.4%;
|
||||||
--sidebar-ring: 221.2 83.2% 53.3%;
|
--sidebar-ring: 0 100% 40%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +47,7 @@
|
|||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 217.2 91.2% 59.8%;
|
--primary: 0 100% 53%;
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
@@ -52,15 +59,15 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 224.3 76.3% 48%;
|
--ring: 0 100% 53%;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 222.2 84% 4.9%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 217.2 91.2% 59.8%;
|
--sidebar-primary: 0 100% 53%;
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 224.3 76.3% 48%;
|
--sidebar-ring: 0 100% 53%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ocean {
|
.ocean {
|
||||||
|
|||||||
@@ -68,11 +68,24 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
|||||||
transition={{ duration: 0.3, delay: 0.2 }}
|
transition={{ duration: 0.3, delay: 0.2 }}
|
||||||
className="h-full"
|
className="h-full"
|
||||||
>
|
>
|
||||||
<Card className="h-full">
|
<Card className="h-full relative overflow-hidden">
|
||||||
|
{/* Spider watermark */}
|
||||||
|
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
|
||||||
|
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
|
||||||
|
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
|
||||||
|
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||||
|
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||||
|
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
|
||||||
|
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
|
||||||
|
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Lead Status</CardTitle>
|
<CardTitle>Lead Status</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-1 flex-col">
|
<CardContent className="flex flex-1 flex-col relative z-10">
|
||||||
<div className="flex flex-1 flex-col items-center justify-center">
|
<div className="flex flex-1 flex-col items-center justify-center">
|
||||||
{/* Donut */}
|
{/* Donut */}
|
||||||
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
|
|||||||
data: IntervalData[]
|
data: IntervalData[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEW_LEADS = "#0d9488"
|
const NEW_LEADS = "#CC0000"
|
||||||
const CLOSED = "#c9a96e"
|
const CLOSED = "#0033CC"
|
||||||
|
|
||||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||||
const [year, setYear] = useState(new Date().getFullYear())
|
const [year, setYear] = useState(new Date().getFullYear())
|
||||||
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
|
|||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#0d9488" />
|
<stop offset="0%" stopColor="#FF1111" />
|
||||||
<stop offset="100%" stopColor={NEW_LEADS} />
|
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="0%" stopColor="#e8d5a3" />
|
<stop offset="0%" stopColor="#1144FF" />
|
||||||
<stop offset="100%" stopColor={CLOSED} />
|
<stop offset="100%" stopColor={CLOSED} />
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<filter id="shadowNew">
|
<filter id="shadowNew">
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
|
|
||||||
export function StatCardSkeleton() {
|
export function StatCardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="h-full">
|
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
<CardContent className="p-6 flex flex-col">
|
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
||||||
<div className="flex items-center justify-between">
|
THWIP!
|
||||||
<div className="space-y-2">
|
</p>
|
||||||
<Skeleton className="h-4 w-24" />
|
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
||||||
<Skeleton className="h-8 w-16" />
|
Loading your data...
|
||||||
</div>
|
</p>
|
||||||
<Skeleton className="h-12 w-12 rounded-xl" />
|
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||||
</div>
|
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ interface StatCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||||
"Total Leads": { bg: "bg-cyan-500/10", text: "text-cyan-600 dark:text-cyan-400", glow: "via-[rgba(6,182,212,0.25)]", accent: "#06b6d4" },
|
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Open Leads": { bg: "bg-blue-500/10", text: "text-blue-600 dark:text-blue-400", glow: "via-[rgba(59,130,246,0.25)]", accent: "#3b82f6" },
|
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
"Contacted": { bg: "bg-amber-500/10", text: "text-amber-600 dark:text-amber-400", glow: "via-[rgba(245,158,11,0.25)]", accent: "#f59e0b" },
|
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Pending": { bg: "bg-violet-500/10", text: "text-violet-600 dark:text-violet-400", glow: "via-[rgba(139,92,246,0.25)]", accent: "#8b5cf6" },
|
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
"Closed": { bg: "bg-yellow-500/10", text: "text-yellow-600 dark:text-yellow-400", glow: "via-[rgba(234,179,8,0.25)]", accent: "#eab308" },
|
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||||
"Conversion Rate": { bg: "bg-rose-500/10", text: "text-rose-600 dark:text-rose-400", glow: "via-[rgba(244,63,94,0.25)]", accent: "#f43f5e" },
|
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeGoal(max: number): number {
|
function computeGoal(max: number): number {
|
||||||
@@ -107,6 +107,10 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||||
const sparkColor = color.accent
|
const sparkColor = color.accent
|
||||||
|
|
||||||
|
const isRed = index % 2 === 0
|
||||||
|
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
||||||
|
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -114,16 +118,50 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
>
|
||||||
<Card className="group h-full hover:shadow-md transition-all duration-200">
|
{/* Web overlay decorative */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comic action lines on hover */}
|
||||||
|
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
|
||||||
|
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||||
|
{/* Red/Blue gradient top strip */}
|
||||||
|
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||||
|
|
||||||
<CardContent className="p-6 flex flex-col">
|
<CardContent className="p-6 flex flex-col">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||||
<p className="text-3xl font-bold tracking-tight">
|
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
||||||
{isNumeric ? display : value}
|
{isNumeric ? display : value}
|
||||||
</p>
|
</p>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
||||||
)}
|
)}
|
||||||
{trend && (
|
{trend && (
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -134,8 +172,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl shrink-0", color.bg)}>
|
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
|
||||||
<Icon className={cn("h-6 w-6", color.text)} />
|
<Icon className={`h-6 w-6 ${color.text}`} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,9 +217,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
|||||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||||
<div className="h-full rounded-full bg-gradient-to-r from-[#f43f5e] to-[#fda4af]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">{conversionRate}% conversion rate</p>
|
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -34,7 +34,52 @@ export function AppShell({ children }: AppShellProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||||
|
style={{
|
||||||
|
backgroundImage: "radial-gradient(circle, #CC000010 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" />
|
||||||
|
|
||||||
|
{/* 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"/>
|
||||||
|
</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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<SystemMonitor />
|
<SystemMonitor />
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ import {
|
|||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Bot,
|
Bot,
|
||||||
|
Facebook,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { COMPANY_NAME } from "@/lib/constants"
|
import { COMPANY_NAME } from "@/lib/constants"
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { useNotifications } from "@/providers/notification-provider"
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
|
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
@@ -43,7 +45,9 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
const { unreadChatCount } = useNotifications()
|
const { unreadChatCount } = useNotifications()
|
||||||
|
const [fbDialogOpen, setFbDialogOpen] = useState(false)
|
||||||
if (!user) return null
|
if (!user) return null
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
const initials = user.name.split(" ").map((n) => n[0]).join("")
|
||||||
|
|
||||||
const sidebarContent = (
|
const sidebarContent = (
|
||||||
@@ -143,6 +147,39 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
{isAdmin && collapsed && (
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => setFbDialogOpen(true)}
|
||||||
|
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Facebook className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="ml-2">
|
||||||
|
Facebook Accounts
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{isAdmin && !collapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => setFbDialogOpen(true)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||||
|
>
|
||||||
|
<Facebook className="h-5 w-5 shrink-0" />
|
||||||
|
<motion.span
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="truncate"
|
||||||
|
>
|
||||||
|
Facebook Accounts
|
||||||
|
</motion.span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Collapse toggle at bottom (only in collapsed mode) */}
|
{/* Collapse toggle at bottom (only in collapsed mode) */}
|
||||||
@@ -189,6 +226,8 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
|||||||
{sidebarContent}
|
{sidebarContent}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
<FacebookAccountsDialog open={fbDialogOpen} onOpenChange={setFbDialogOpen} />
|
||||||
|
|
||||||
{/* Mobile sidebar overlay */}
|
{/* Mobile sidebar overlay */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
|
|||||||
@@ -67,11 +67,18 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-4 lg:px-6">
|
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-white dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
|
||||||
|
{/* Red/Blue diagonal split accent */}
|
||||||
|
<div className="absolute right-0 top-0 bottom-0 w-[6px] bg-gradient-to-b from-[#CC0000] via-[#0033CC] to-[#CC0000] dark:from-[#FF1111] dark:via-[#1144FF] dark:to-[#FF1111]" />
|
||||||
|
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||||
<div className="w-3 h-3 rounded-full bg-[#0d9488]" />
|
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<span className="text-[#0d9488] font-bold text-sm tracking-wide">
|
<circle cx="14" cy="14" r="13" fill="#CC0000" />
|
||||||
|
<circle cx="14" cy="14" r="8" fill="#0033CC" />
|
||||||
|
<path d="M14 6 L16 12 L22 12 L17 15 L19 21 L14 17 L9 21 L11 15 L6 12 L12 12 Z" fill="white" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
|
||||||
CRM
|
CRM
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,10 +95,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
|||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
<div className="relative hidden flex-1 sm:block md:w-80">
|
||||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search leads, companies..."
|
placeholder="Search leads, companies..."
|
||||||
className="h-9 w-full max-w-sm pl-9 bg-muted/50"
|
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
|
import { ShieldAlert, RefreshCw } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
|
||||||
|
interface Account {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
is_active: boolean
|
||||||
|
last_scrape_at: string | null
|
||||||
|
last_success_at: string | null
|
||||||
|
last_error_at: string | null
|
||||||
|
consecutive_failures: number
|
||||||
|
flagged: boolean
|
||||||
|
flagged_reason: string | null
|
||||||
|
last_leads_found: number
|
||||||
|
last_success: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FacebookAccountsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([])
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) load()
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/settings/facebook/accounts")
|
||||||
|
if (res.ok) {
|
||||||
|
setAccounts(await res.json())
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl">
|
||||||
|
<DialogHeader className="flex flex-row items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<DialogTitle>Facebook Accounts</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Status of Facebook profiles used for lead scraping
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="icon" onClick={load} disabled={loading}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||||
|
</Button>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">Loading...</div>
|
||||||
|
) : accounts.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">No accounts configured</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Label</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Last Scrape</TableHead>
|
||||||
|
<TableHead>Leads</TableHead>
|
||||||
|
<TableHead>Failures</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{accounts.map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell className="font-medium">{a.label}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{a.flagged ? (
|
||||||
|
<Badge variant="destructive" className="gap-1">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
{a.flagged_reason || "Flagged"}
|
||||||
|
</Badge>
|
||||||
|
) : a.is_active ? (
|
||||||
|
<Badge variant="default" className="bg-green-600">Active</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">Disabled</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{a.last_scrape_at ? new Date(a.last_scrape_at).toLocaleString() : "Never"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{a.last_leads_found}</TableCell>
|
||||||
|
<TableCell>{a.consecutive_failures}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
|
|||||||
className={cn("flex items-center justify-between pb-6", className)}
|
className={cn("flex items-center justify-between pb-6", className)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#0d9488] pl-4">{title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
|
||||||
{description && <p className="text-sm text-muted-foreground mt-1">{description}</p>}
|
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{children && <div className="flex items-center gap-3">{children}</div>}
|
{children && <div className="flex items-center gap-3">{children}</div>}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ name: "Open", value: statusCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: statusCounts.open, color: "#CC0000" },
|
||||||
{ name: "Contacted", value: statusCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
|
||||||
{ name: "Pending", value: statusCounts.pending, color: "#8b5cf6" },
|
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
|
||||||
{ name: "Closed", value: statusCounts.closed, color: "#10b981" },
|
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" },
|
||||||
{ name: "Ignored", value: statusCounts.ignored, color: "#6B7280" },
|
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user