feat: Enhance provider components with detailed documentation and comments

- Added comprehensive comments and JSDoc-style documentation to NotificationProvider, ThemeProvider, UserProvider, and WebsiteThemeProvider for better clarity and maintainability.
- Improved type definitions in index.ts for better code understanding and usage.
- Introduced Docker support with Dockerfiles for various services including AI server, signaling server, and browser-use service.
- Created a docker-compose.yml file to orchestrate multiple services including PostgreSQL, AI, scraper, and frontend.
- Added a startup guide (startup.txt) for setting up the CRM environment on Ubuntu with Docker.
- Included a .dockerignore file to exclude unnecessary files from Docker builds.
This commit is contained in:
Ace
2026-07-13 13:05:30 +02:00
parent dba4c84cd5
commit d35c806d5b
167 changed files with 3474 additions and 102 deletions
+12
View File
@@ -0,0 +1,12 @@
node_modules
.next
.git
*.md
.vscode
__pycache__
*.pyc
.DS_Store
.env.local
.env
.gitignore
.prettierrc
+23
View File
@@ -0,0 +1,23 @@
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-slim AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NEXT_PUBLIC_SCRAPER_URL=http://localhost:3008
ENV NEXT_PUBLIC_SCRAPER_URL=$NEXT_PUBLIC_SCRAPER_URL
RUN npm run build
FROM node:20-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3006
CMD ["./node_modules/.bin/next", "start", "-p", "3006"]
+7
View File
@@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY signaling-server.mjs ./
EXPOSE 3007
CMD ["node", "signaling-server.mjs"]
+9
View File
@@ -1,6 +1,14 @@
// ── Web Calendar Types ──────────────────────────────────────────────
// Core calendar event types used across the CRM system, including
// event classification, status tracking, and participant metadata.
/** Possible types of calendar events — triggered by different CRM workflows. */
export type EventType = "call" | "follow_up" | "website_creation"
/** Lifecycle state of a calendar event. */
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
/** A single calendar entry linking a user, participant, developer, and optional lead/conversation. */
export interface CalendarEvent {
id: string
userId: string
@@ -16,6 +24,7 @@ export interface CalendarEvent {
endTime: string | null
durationMinutes: number | null
status: EventStatus
// Denormalized user references for display (avoid extra joins in list views)
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
+12
View File
@@ -0,0 +1,12 @@
FROM node:20-slim
RUN apt-get update && apt-get install -y --no-install-recommends postgresql-client && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY ai-server/ ./ai-server/
COPY splash.html ./
COPY data/ ./data/
COPY scripts/run-migrations.mjs ./scripts/run-migrations.mjs
COPY database/ ./database/
EXPOSE 3001
CMD ["node", "ai-server/index.mjs"]
+9
View File
@@ -60,6 +60,7 @@ let pullProgress = { status: "idle", progress: 0, message: "" }
// ── Job loading ─────────────────────────────────────────────────
// Loads job categories from a JSONL file (one JSON object per line).
// Used as context for the AI sales coach chat responses.
/** Load job categories from the JSONL file at JOBS_PATH. Returns an array of parsed job objects. */
function loadJobs() {
try {
const content = fs.readFileSync(JOBS_PATH, "utf-8")
@@ -86,6 +87,7 @@ function loadJobs() {
// ── ai.md management ────────────────────────────────────────────
// ai.md is a Markdown file containing system instructions for the AI.
// It can be read, written, or appended to via the API.
/** Read the full contents of ai.md. Returns empty string if the file doesn't exist yet. */
function readInstructions() {
try {
return fs.readFileSync(AI_MD_PATH, "utf-8")
@@ -94,11 +96,13 @@ function readInstructions() {
}
}
/** Overwrite ai.md with new content. Returns the content that was written. */
function writeInstructions(content) {
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
return content
}
/** Append a timestamped entry to the ## Improvement Log section of ai.md. Creates the section if it doesn't exist. */
function appendToImprovementLog(entry) {
// Adds a timestamped entry to the ## Improvement Log section of ai.md
const current = readInstructions()
@@ -153,6 +157,7 @@ async function scrapeFacebook() {
}
}
/** Format scraped Facebook leads into a human-readable Markdown string for the AI chat response. */
function formatLeads(leads) {
if (!leads || leads.length === 0) return "No leads found from the latest scrape."
let output = `**${leads.length} leads found:**\n\n`
@@ -239,6 +244,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
// ── PG pool (lazy init) ────────────────────────────────────────
// PostgreSQL connection pool for storing conversation history.
// Lazy-initialized so the server starts even without a DB.
/** Lazy-initialize the PostgreSQL connection pool. Non-blocking — server works without a database. */
let pgPool = null
async function initPg() {
if (!DATABASE_URL) return
@@ -255,11 +261,13 @@ async function initPg() {
// ── Request router ─────────────────────────────────────────────
const loadedJobs = loadJobs()
/** Write a JSON response with the given HTTP status code. */
function sendJSON(res, status, data) {
res.writeHead(status, { "Content-Type": "application/json" })
res.end(JSON.stringify(data))
}
/** Parse the JSON body of an incoming HTTP request. */
function parseBody(req) {
return new Promise((resolve, reject) => {
let body = ""
@@ -275,6 +283,7 @@ function parseBody(req) {
})
}
/** Parse a request URL into its pathname and search params. Falls back to localhost if no Host header is present. */
function parseURL(req) {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
return { pathname: url.pathname, searchParams: url.searchParams }
+15
View File
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 \
libdrm2 libdbus-1-3 libxkbcommon0 libxcomposite1 libxdamage1 \
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 \
libatspi2.0-0 libwayland-client0 libxshmfence1 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
RUN playwright install --with-deps firefox chromium 2>&1 | tail -5
COPY main.py ./
ENV PYTHONUNBUFFERED=1
EXPOSE 3008
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3008"]
+111
View File
@@ -0,0 +1,111 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: crm
POSTGRES_USER: crm
POSTGRES_PASSWORD: ${DB_PASSWORD:-crm}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U crm"]
interval: 5s
timeout: 5s
retries: 5
ollama:
image: ollama/ollama
restart: unless-stopped
volumes:
- ollama-models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 30s
timeout: 10s
retries: 3
migrate:
build:
context: .
dockerfile: ai-server/Dockerfile
command: ["node", "scripts/run-migrations.mjs"]
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
depends_on:
db:
condition: service_healthy
restart: "no"
ai:
build:
context: .
dockerfile: ai-server/Dockerfile
restart: unless-stopped
ports:
- "3001:3001"
environment:
AI_PORT: 3001
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
OLLAMA_BASE_URL: http://ollama:11434
SCRAPER_URL: http://scraper:3008
FRONTEND_URL: http://next:3006
AI_MODEL: ${AI_MODEL:-dolphin-llama3:8b}
JWT_SECRET: ${JWT_SECRET}
SELECTED_BROWSER: ${SELECTED_BROWSER:-firefox}
depends_on:
db:
condition: service_healthy
ollama:
condition: service_started
scraper:
build:
context: ./browser-use-service
restart: unless-stopped
ports:
- "3008:3008"
environment:
PORT: 3008
OLLAMA_URL: http://ollama:11434
CLASSIFY_MODEL: ${CLASSIFY_MODEL:-dolphin-llama3:8b}
CORS_ORIGINS: http://localhost:3006,http://next:3006
depends_on:
- ollama
next:
build:
context: .
args:
NEXT_PUBLIC_SCRAPER_URL: ${NEXT_PUBLIC_SCRAPER_URL:-http://localhost:3008}
restart: unless-stopped
ports:
- "3006:3006"
environment:
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
AI_SERVICE_URL: http://ai:3001
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
signaling:
build:
context: .
dockerfile: Dockerfile.signaling
restart: unless-stopped
ports:
- "3007:3007"
environment:
SIGNALING_PORT: 3007
DATABASE_URL: postgres://crm:${DB_PASSWORD:-crm}@db:5432/crm
JWT_SECRET: ${JWT_SECRET}
depends_on:
db:
condition: service_healthy
volumes:
pgdata:
ollama-models:
+5
View File
@@ -1,8 +1,13 @@
// ── ESLint Config ──────────────────────────────────────────────────
// Extends Next.js core-web-vitals + TypeScript recommended rules.
// Ignores build output directories and auto-generated type stubs.
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals.js";
import nextTs from "eslint-config-next/typescript.js";
const eslintConfig = defineConfig([
// Next.js core Web Vitals rules + TypeScript strict checks
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
+8
View File
@@ -1,9 +1,15 @@
// ── Next.js Config ──────────────────────────────────────────────────
// Security headers, image remote patterns, and build-time ESLint checks.
// Applied globally to all routes via the headers() async function.
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
// Fail the build on ESLint errors — no silent ignores
eslint: {
ignoreDuringBuilds: false,
},
// Allow external avatar images from ui-avatars.com
images: {
remotePatterns: [
{
@@ -12,6 +18,8 @@ const nextConfig: NextConfig = {
},
],
},
// ── Security Headers ──────────────────────────────────────────────
// Applied to all routes. HSTS is production-only to avoid localhost issues.
async headers() {
return [
{
+4
View File
@@ -1,3 +1,7 @@
// ── PostCSS Config ─────────────────────────────────────────────────
// Tailwind CSS v4 + Autoprefixer for cross-browser vendor prefixes.
// Minimal — both plugins run with defaults.
const config = {
plugins: {
tailwindcss: {},
+28 -9
View File
@@ -1,17 +1,30 @@
<#
.SYNOPSIS
PostgreSQL database backup with retention management and logging.
.DESCRIPTION
Uses pg_dump (custom format) to back up the CRM database. Tracks the
backup in the backup_logs table and auto-prunes files older than
$RetentionDays. Reads DATABASE_URL from the environment or .env.local.
.PARAMETER BackupDir
Directory where backup files are stored (default: ../backups).
.PARAMETER RetentionDays
Number of days to retain backups (default: 30).
#>
param(
[string]$BackupDir = "$PSScriptRoot\..\backups",
[int]$RetentionDays = 30
)
# Fail fast on any error
$ErrorActionPreference = "Stop"
$startTime = Get-Date
# Ensure backup directory exists
# ── Ensure backup directory exists ────────────────────────────────
if (-not (Test-Path $BackupDir)) {
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
}
# Determine database URL from env or .env.local
# ── Determine database URL from env or .env.local ─────────────────
$dbUrl = $env:DATABASE_URL
if (-not $dbUrl -and (Test-Path "$PSScriptRoot\..\.env.local")) {
$envContent = Get-Content "$PSScriptRoot\..\.env.local" -Raw
@@ -25,7 +38,7 @@ if (-not $dbUrl) {
exit 1
}
# Parse the database URL
# ── Parse the database URL into connection components ─────────────
$uri = [System.Uri]$dbUrl
$pgUser = $uri.UserInfo.Split(':')[0]
$pgPass = $uri.UserInfo.Split(':')[1]
@@ -33,7 +46,7 @@ $pgHost = $uri.Host
$pgPort = $uri.Port
$pgDb = $uri.AbsolutePath.TrimStart('/')
# Set PGPASSWORD for pg_dump
# pg_dump reads password from this env var (avoids interactive prompt)
$env:PGPASSWORD = $pgPass
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
@@ -44,13 +57,15 @@ Write-Output "Starting backup of database '$pgDb' to $filepath"
Write-Output "Database: $pgHost:$pgPort/$pgDb"
try {
# Run pg_dump
# ── Run pg_dump (custom format) ──────────────────────────────
# Check PATH first, then fall back to the bundled psql location
$pgDumpPath = if (Get-Command pg_dump -ErrorAction SilentlyContinue) {
"pg_dump"
} else {
"$env:TEMP\pg\pgsql\bin\pg_dump.exe"
}
# Custom format (-Fc) for compressed, restorable output
$output = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --verbose --file $filename 2>&1
$exitCode = $LASTEXITCODE
@@ -58,11 +73,13 @@ try {
throw "pg_dump failed with exit code $exitCode"
}
# Move to final backup location (dg_dump writes to CWD first)
Move-Item -Path $filename -Destination $filepath -Force
$fileInfo = Get-Item $filepath
$fileSize = $fileInfo.Length
# Verify backup by checking if file is valid custom format
# ── Verify backup integrity ──────────────────────────────────
# Run a schema-only dump against the same file to check it's valid custom format
$verifyOutput = & $pgDumpPath -h $pgHost -p $pgPort -U $pgUser -d $pgDb --format=custom --schema-only --file "$filename.verify" 2>&1
$verifyExitCode = $LASTEXITCODE
if (Test-Path "$filename.verify") { Remove-Item "$filename.verify" -Force }
@@ -73,7 +90,8 @@ try {
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
# Log the backup
# ── Log backup to database ───────────────────────────────────
# Records metadata in backup_logs table for audit trail
$logQuery = @"
INSERT INTO backup_logs (backup_type, status, file_name, file_size_bytes, pg_dump_exit_code, verification_status, started_at, completed_at, duration_seconds, retention_days)
VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationStatus', '$($startTime.ToString("yyyy-MM-dd HH:mm:ss"))', '$($endTime.ToString("yyyy-MM-dd HH:mm:ss"))', $durationSeconds, $RetentionDays);
@@ -93,7 +111,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt
Write-Output " Duration: ${durationSeconds}s"
Write-Output " Status: $verificationStatus"
# Cleanup old backups (beyond retention period)
# ── Cleanup old backups beyond retention period ──────────────
$cutoff = (Get-Date).AddDays(-$RetentionDays)
$oldBackups = Get-ChildItem $BackupDir -Filter "crm_backup_*.sql" | Where-Object {
$_.CreationTime -lt $cutoff
@@ -106,7 +124,7 @@ VALUES ('full', 'completed', '$filename', $fileSize, $exitCode, '$verificationSt
} catch {
Write-Error "Backup failed: $_"
# Log failure
# Log failure to database for monitoring
$endTime = Get-Date
$durationSeconds = [math]::Round(($endTime - $startTime).TotalSeconds)
$errorMsg = $_.ToString().Replace("'", "''")
@@ -121,5 +139,6 @@ VALUES ('full', 'failed', '$filename', '$errorMsg', $exitCode, 'failed', '$($sta
exit 1
} finally {
# Clean up the password environment variable for security
Remove-Item "Env:PGPASSWORD" -ErrorAction SilentlyContinue
}
+17 -1
View File
@@ -28,19 +28,23 @@ import { createRequire } from "node:module"
const SELF = dirname(fileURLToPath(import.meta.url))
const _require = createRequire(import.meta.url)
const ROOT = resolve(SELF, "..")
// Ollama endpoint and model can be overridden via environment variables
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
// Safety limits: max repair iterations, minimum AI confidence to auto-apply, escalation timeout
const MAX_ITERATIONS = 5
const CONFIDENCE_THRESHOLD = 0.7
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
// ── Helpers ────────────────────────────────────────────────────────
/** Log a message with a level-based prefix for visual scanning of output. */
function log(msg, level = "info") {
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
console.log(` ${prefix} [repair] ${msg}`)
}
/** Run `npx tsc --noEmit` and return the output (success or failure). */
function runTsc() {
try {
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
@@ -50,6 +54,7 @@ function runTsc() {
}
}
/** Parse TypeScript compiler output into structured error objects with file, line, column, and message. */
function parseErrors(output) {
const errors = []
// Match: src/file.ts(line,col): error TSxxxx: message
@@ -66,6 +71,7 @@ function parseErrors(output) {
return errors
}
/** Read source file lines surrounding the error, returning a snippet with context line offset. */
function readContext(filePath, errorLine, contextLines = 20) {
try {
const lines = readFileSync(filePath, "utf-8").split("\n")
@@ -83,6 +89,7 @@ function readContext(filePath, errorLine, contextLines = 20) {
// ── Ollama Integration ─────────────────────────────────────────────
/** Send a repair prompt to the Ollama model and return the raw response text. */
async function queryOllama(prompt) {
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
@@ -99,6 +106,7 @@ async function queryOllama(prompt) {
return data.response || ""
}
/** Build a structured prompt for the AI model, including file path, error message, and surrounding code context. */
function buildRepairPrompt(filePath, errorMessage, codeContext) {
return `You are a TypeScript repair agent. Fix the following error.
@@ -122,6 +130,7 @@ Rules:
- If you cannot determine a fix, set confidence to 0`
}
/** Parse the AI's JSON response (handles optional markdown fences around the JSON block). */
function parseFixResponse(text) {
try {
// Extract JSON from response (it might have markdown fences)
@@ -133,6 +142,7 @@ function parseFixResponse(text) {
}
}
/** Apply a fix suggestion to disk, returning success/failure and a backup of the original content. */
function applyFix(filePath, fixSuggestion) {
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
@@ -147,8 +157,8 @@ function applyFix(filePath, fixSuggestion) {
// ── Breaking Change Escalation ─────────────────────────────────────
/** Heuristic check: if any fix touches imports, exports, or deletions, consider it a breaking change. */
function isBreakingChange(errors, fixes) {
// Heuristic: if fix touches import structure or exports, it's breaking
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
@@ -158,6 +168,7 @@ function isBreakingChange(errors, fixes) {
return false
}
/** Log the breaking change, wait for the escalation timeout, then auto-apply. */
async function escalateBreakingChange(errors, fixes) {
const logFile = resolve(ROOT, "repair-escalation.log")
const timestamp = new Date().toISOString()
@@ -181,6 +192,7 @@ async function escalateBreakingChange(errors, fixes) {
// ── Git Auto-Commit ────────────────────────────────────────────────
/** Git commit all changes with a [bot]-prefixed message. Returns false on failure (non-blocking). */
function gitCommit(message) {
try {
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
@@ -195,6 +207,7 @@ function gitCommit(message) {
// ── Main Repair Loop ───────────────────────────────────────────────
/** Single pass: run tsc, parse errors, ask AI for fixes, apply them, and verify. Returns result summary. */
async function repairOnce(doCommit = false) {
log("Running build check...")
const build = runTsc()
@@ -276,6 +289,7 @@ async function repairOnce(doCommit = false) {
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
}
/** Run up to maxIterations repair passes, stopping once all errors are resolved or no progress is made. */
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
for (let i = 0; i < maxIterations; i++) {
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
@@ -293,8 +307,10 @@ async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
// ── Watch Mode ─────────────────────────────────────────────────────
/** Watch mode: monitor src/ for file changes and auto-repair errors. Falls back to polling if chokidar is unavailable. */
function watchMode() {
log("Watch mode active — monitoring src/ for changes...", "info")
// Try loading chokidar; if not installed, fall back to simple interval polling
const chokidar = (() => {
try {
return _require("chokidar")
+3 -1
View File
@@ -7,6 +7,7 @@
import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
/** Check if Ollama is already running by querying the OS process list. */
function isRunning() {
try {
if (platform() === "win32") {
@@ -20,6 +21,7 @@ function isRunning() {
}
}
/** Locate the Ollama binary via PATH, then fall back to common install directories. */
function findOllama() {
if (platform() === "win32") {
// Try PATH first, then common install locations
@@ -49,7 +51,7 @@ if (!isRunning()) {
} else {
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
}
// Give it a moment to start listening
// Give it a moment to start listening before we return control
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
} else {
console.log("Ollama already running")
+19 -10
View File
@@ -1,6 +1,8 @@
// ── Module Generator ─────────────────────────────────────────────────
// Generates missing internal modules from templates.
// Supports built-in templates and custom .setup-templates/ directory.
// Part of the self-healing setup pipeline — invoked when tsc reports
// TS2307 errors for @/ imports that don't exist yet.
import fs from "node:fs";
import path from "node:path";
@@ -12,6 +14,7 @@ const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
/** Maps internal import paths (e.g. "data/stickers") to their template file and optional params. */
export interface TemplateRegistry {
[importPath: string]: {
template: string;
@@ -20,6 +23,7 @@ export interface TemplateRegistry {
};
}
/** Result of a single module generation attempt. */
export interface GenerationResult {
success: boolean;
filePath: string;
@@ -28,18 +32,21 @@ export interface GenerationResult {
error?: string;
}
/** Maps glob patterns (e.g. "@/hooks/*") to fallback template file names. */
export interface FallbackRegistry {
[pattern: string]: string;
}
/** Load the template registry from .setup-templates/registry.json */
/** Load the template registry from .setup-templates/registry.json, merging with built-in templates. */
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
// Built-in mappings for the most commonly imported modules
const builtInTemplates: TemplateRegistry = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
};
// Directory-level catch-all fallbacks for unknown imports
const builtInFallbacks: FallbackRegistry = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
@@ -49,6 +56,7 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
"@/utils/*": "utils-generic.ts",
};
// Merge in any custom templates from the .setup-templates directory
try {
if (fs.existsSync(REGISTRY_PATH)) {
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
@@ -64,10 +72,10 @@ export function loadRegistry(): { templates: TemplateRegistry; fallbacks: Fallba
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path against fallback patterns */
/** Match an internal path (e.g. "data/stickers") against glob fallback patterns. Converts glob to regex internally. */
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
for (const [pattern, template] of Object.entries(fallbacks)) {
// Convert glob pattern to regex
// Convert glob pattern (e.g. "@/hooks/*") to a regular expression
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
@@ -82,7 +90,7 @@ export function matchFallback(internalPath: string, fallbacks: FallbackRegistry)
return undefined;
}
/** Render a template with parameters */
/** Render a template string by replacing {{ paramName }} placeholders with actual values. */
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
let result = templateContent;
for (const [key, value] of Object.entries(params)) {
@@ -92,7 +100,7 @@ function renderTemplate(templateContent: string, params: Record<string, unknown>
return result;
}
/** Get template content - first from custom templates dir, then built-in */
/** Get template content first tries the custom .setup-templates/templates dir, then falls back to built-in embedded templates. */
function getTemplateContent(templateName: string): string {
// Check custom templates first
const customPath = path.join(TEMPLATES_DIR, templateName);
@@ -100,7 +108,7 @@ function getTemplateContent(templateName: string): string {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded)
// Built-in templates (embedded in the script — no external file needed)
const builtInTemplates: Record<string, string> = {
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
@@ -312,16 +320,16 @@ export interface DashboardStats {
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module */
/** Generate a single missing module: resolve template, render parameters, write to src/. */
export function generateModule(
internalPath: string,
templates: TemplateRegistry,
fallbacks: FallbackRegistry
): GenerationResult {
// Look up exact template first, then fall back to directory-level glob patterns
let entry = templates[internalPath];
let templateFile = entry?.template;
// Try fallback patterns
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
@@ -349,10 +357,11 @@ export function generateModule(
};
}
// Replace template parameter placeholders with actual values
const rendered = renderTemplate(templateContent, entry.params || {});
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
// Ensure directory exists
// Ensure directory exists before writing
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
@@ -377,7 +386,7 @@ export function generateModule(
}
}
/** Generate all missing modules for a list of internal paths */
/** Generate all missing modules for a list of internal paths, logging each result. */
export function generateAll(internalPaths: string[]): GenerationResult[] {
const { templates, fallbacks } = loadRegistry();
const results: GenerationResult[] = [];
+4 -3
View File
@@ -2,6 +2,7 @@
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
// Used by both setup.mjs (self-heal) and code-repair-agent.mjs.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
@@ -20,7 +21,7 @@ export interface MissingModule {
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors */
/** Parse TypeScript compiler output for missing module errors (TS2307 + webpack-style "Can't resolve"). */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
@@ -74,12 +75,12 @@ export function parseMissingModules(tscOutput: string): MissingModule[] {
});
}
/** Filter to only internal (@/...) modules we can auto-generate */
/** Filter to only internal (@/... or ~/...) modules we can auto-generate from templates. */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by internal path */
/** Group missing modules by their normalized internal path (deduplicates same module imported from multiple files). */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
+3
View File
@@ -9,11 +9,14 @@ import { platform } from "node:os"
const url = process.argv[2] || "http://localhost:3001/splash"
/** Promise-based sleep helper for the startup delay. */
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function main() {
// Wait for the AI server, scraper, and frontend to finish booting
await sleep(8000)
try {
// Platform-specific command to open the default browser
if (platform() === "win32") {
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
} else if (platform() === "darwin") {
+2
View File
@@ -12,6 +12,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
// Read package.json and check each dependency's package.json in node_modules
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
@@ -45,6 +46,7 @@ if (platform() === "win32") {
const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
const lines = out.trim().split("\n").filter(Boolean)
for (const line of lines) {
// Parse the last column of netstat output — the PID
const parts = line.trim().split(/\s+/)
const pid = parts[parts.length - 1]
if (pid) {
+15 -4
View File
@@ -14,6 +14,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
// Simple key=value parser (no dotenv dependency needed).
// Handles quoted values and skips comments / blank lines.
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
@@ -24,9 +26,11 @@ if (existsSync(envPath)) {
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
// Strip matching surrounding quotes (single or double)
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
// Don't override already-set env vars
if (!process.env[key]) process.env[key] = value
}
}
@@ -38,6 +42,7 @@ if (!DATABASE_URL) {
}
// ── Find psql ────────────────────────────────────────────────────────
// Check PATH first, then probe common PostgreSQL v1417 install paths.
function findPsql() {
try {
@@ -84,8 +89,9 @@ try {
// ── Migration logic ──────────────────────────────────────────────────
/** Run all unapplied database migrations in the order defined by run_all.sql. */
async function runMigrations() {
// 1. Create tracking table if not exists
// 1. Ensure the _migrations tracking table exists (idempotent)
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
@@ -94,11 +100,12 @@ async function runMigrations() {
)
`)
// 2. Determine file order from run_all.sql (authoritative)
// 2. Determine file order from run_all.sql (authoritative source of truth)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
// `\i filename.sql` is the psql include directive
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
@@ -117,7 +124,7 @@ async function runMigrations() {
appliedSet = new Set()
}
// 4. Build psql command base
// 4. Build psql command base with ON_ERROR_STOP=1 so psql aborts on first error
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
@@ -133,6 +140,7 @@ async function runMigrations() {
let count = 0
for (const file of fileOrder) {
// Skip files already recorded in the _migrations table
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
@@ -149,12 +157,14 @@ async function runMigrations() {
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success
// psql outputs notices/warnings to stderr even on success — capture for diagnostics
stderr = result.stderr || ""
} catch (err) {
// Distinguish between "already applied" (harmless) and genuine failures
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
// File was applied before tracking existed — record it and continue
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
@@ -165,6 +175,7 @@ async function runMigrations() {
process.exit(1)
}
// Record successful migration
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
+4 -1
View File
@@ -8,7 +8,9 @@ import { execSync, spawn } from "node:child_process"
import { platform } from "node:os"
import { statSync } from "node:fs"
/** Locate the Python 3 executable. Checks common install paths first, then the system PATH. */
function detectPython() {
// Pre-check common Windows install directories to avoid PATH lookup
const commonPaths = [
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
@@ -21,6 +23,7 @@ function detectPython() {
return p
} catch {}
}
// Fall back to PATH resolution (prefer python3 on Unix)
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
try {
@@ -42,6 +45,6 @@ if (!script) {
process.exit(1)
}
// Spawn Python with inherited stdio so the script's output is visible
// Spawn Python with inherited stdio so the script's output is visible in real-time
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
proc.on("exit", (code) => process.exit(code ?? 1))
+20 -5
View File
@@ -15,12 +15,14 @@ import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
// Shell command separator differs per platform (Windows uses &, POSIX uses ;)
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
/** Try known Python command names until one succeeds, then exit if none found. */
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
@@ -33,6 +35,7 @@ function detectPython() {
process.exit(1)
}
/** Detect pip; fall back to `python -m pip` if no standalone pip binary exists. */
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
@@ -44,6 +47,7 @@ function detectPip(python) {
return `${python} -m pip`
}
/** Run a shell command with label output. Exits on failure unless opts.optional is set. */
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
@@ -60,11 +64,15 @@ function run(cmd, label, opts = {}) {
}
// ── Self-Healing Module Registry ───────────────────────────────────
// Maps internal import paths (like @/data/stickers) to template files.
// Supports custom templates in .setup-templates/ that override built-ins.
// Fallback patterns catch whole directories (e.g. @/hooks/* → hook-generic.ts).
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
// Hard-coded templates for commonly imported modules
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
@@ -75,6 +83,7 @@ function loadRegistry() {
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
// Catch-all fallbacks for entire directory prefixes
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
@@ -87,6 +96,7 @@ function loadRegistry() {
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
// Merge custom templates from .setup-templates/registry.json (if it exists)
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
@@ -97,6 +107,7 @@ function loadRegistry() {
const templateCache = {}
/** Read a template file, checking the custom templates dir first, then hooks subdirectory. */
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
@@ -113,6 +124,7 @@ function loadRegistry() {
return ""
}
/** Convert a glob pattern (e.g. @/hooks/*) to a regex and test against the internal path. */
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
@@ -125,6 +137,7 @@ function loadRegistry() {
return null
}
/** Generate (or regenerate) a single module file from its template. */
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
@@ -143,6 +156,7 @@ function loadRegistry() {
return { generateModule }
}
/** Parse `tsc --noEmit` or `npm run build` output for missing-module errors, returning unique import paths. */
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
@@ -177,21 +191,22 @@ function parseMissingModules(buildOutput) {
// ── Main ───────────────────────────────────────────────────────────
// Detect Python and pip executables before running any steps
const PY = detectPython()
const PIP = detectPip(PY)
console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies
// Step 1 — Install all Node.js dependencies (includes next, react, etc.)
run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies
// Step 2 — Install Python deps for the browser-use service (Playwright-based scraper)
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers
// Step 3 — Download Playwright browser binaries for scraper automation
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file
// Step 4 — Bootstrap .env.local from the example template (won't overwrite existing)
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
@@ -200,7 +215,7 @@ if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Final summary
// Step 5 — Print post-setup instructions
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
+63
View File
@@ -1,8 +1,20 @@
// ── WebRTC Signaling Server ─────────────────────────────────────────
// Provides real-time signaling for the CRM chat system using Socket.IO:
// - JWT-based authentication middleware
// - Online user presence tracking (online/offline)
// - Peer-to-peer call signaling (offer/answer/ICE candidates)
// - Multi-participant room-based WebRTC (join/leave/relay)
// - Message deletion broadcast to conversation participants
//
// Clients authenticate via socket handshake auth token; unauthenticated
// users can still join rooms as anonymous participants.
import { createServer } from "http"
import { Server } from "socket.io"
import { SignJWT, jwtVerify } from "jose"
import pg from "pg"
// ── Configuration ─────────────────────────────────────────────────
const PORT = process.env.SIGNALING_PORT || 3007
const rawSecret = process.env.JWT_SECRET
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
@@ -10,14 +22,23 @@ const JWT_SECRET = new TextEncoder().encode(rawSecret)
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
// ── Database pool ─────────────────────────────────────────────────
const pool = new pg.Pool({ connectionString: DATABASE_URL })
// ── HTTP + Socket.IO server ───────────────────────────────────────
const httpServer = createServer()
const io = new Server(httpServer, { cors: { origin: "*", methods: ["GET", "POST"] } })
// ── In-memory state ───────────────────────────────────────────────
// onlineUsers maps userId -> socketId for presence tracking.
// rooms maps roomId -> array of { socketId, id, label } participants.
const onlineUsers = new Map()
const rooms = new Map()
// ── Authentication middleware ─────────────────────────────────────
// Verifies the JWT token from handshake auth. If valid, attaches
// userId and role to the socket's data bag. Unauthenticated sockets
// proceed with null user info.
io.use((socket, next) => {
const token = socket.handshake.auth?.token
if (token) {
@@ -39,15 +60,25 @@ io.use((socket, next) => {
}
})
// ── Connection handler ────────────────────────────────────────────
// Registers authenticated users as online, then wires up all
// per-socket event listeners for signaling and presence.
io.on("connection", (socket) => {
const userId = socket.data.userId
// Mark the user online and broadcast to other clients
if (userId) {
onlineUsers.set(userId, socket.id)
socket.broadcast.emit("user:online", { userId })
}
// ── Authenticated event handlers ──────────────────────────────
// Only users with a valid JWT token may use these features.
if (userId) {
// user:lookup — looks up a user by phone number in the database.
// Fires when the client searches for a contact to start a call.
// Returns { found, user } or { found: false }.
socket.on("user:lookup", async ({ phone }, callback) => {
try {
const result = await pool.query(
@@ -79,6 +110,9 @@ io.on("connection", (socket) => {
}
})
// call:offer — relays an SDP offer to the target user.
// Fires when the caller initiates a WebRTC call.
// callerInfo is fetched from the DB if not supplied.
socket.on("call:offer", async ({ to, sdp, callerInfo }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -112,6 +146,8 @@ io.on("connection", (socket) => {
}
})
// call:answer — relays an SDP answer back to the caller.
// Fires when the callee accepts the incoming call.
socket.on("call:answer", ({ to, sdp }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -119,6 +155,8 @@ io.on("connection", (socket) => {
}
})
// call:ice-candidate — relays ICE candidates between peers.
// Fires during connection negotiation for NAT traversal.
socket.on("call:ice-candidate", ({ to, candidate }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -126,6 +164,7 @@ io.on("connection", (socket) => {
}
})
// call:end — notifies the remote peer that the call ended.
socket.on("call:end", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -133,6 +172,7 @@ io.on("connection", (socket) => {
}
})
// call:reject — tells the caller the callee rejected the call.
socket.on("call:reject", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -140,6 +180,7 @@ io.on("connection", (socket) => {
}
})
// call:busy — tells the caller the callee is on another call.
socket.on("call:busy", ({ to }) => {
const targetSocketId = onlineUsers.get(to)
if (targetSocketId) {
@@ -147,6 +188,9 @@ io.on("connection", (socket) => {
}
})
// message:deleted — broadcasts a deletion event to all other
// participants in the conversation. Looks up participant IDs
// from the database so only relevant clients are notified.
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
try {
const result = await pool.query(
@@ -167,6 +211,15 @@ io.on("connection", (socket) => {
})
}
// ── Room-based signaling (authenticated + anonymous) ──────────
// These events support multi-participant rooms used for features
// like screen sharing or group calls. Anonymous users are assigned
// an "anon-" prefix ID.
// room:join — adds the socket to a named room. When the first
// participant joins they receive "room:waiting". When the second
// joins the first is told to initiate an offer. Additional
// participants are notified normally.
socket.on("room:join", ({ roomId, displayName }) => {
const participantId = userId || `anon-${socket.id}`
const participantLabel = displayName || participantId
@@ -191,18 +244,24 @@ io.on("connection", (socket) => {
}
})
// room:offer — relays an SDP offer to all other room participants.
socket.on("room:offer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:offer", { sdp })
})
// room:answer — relays an SDP answer to all other room participants.
socket.on("room:answer", ({ roomId, sdp }) => {
socket.to(roomId).emit("room:answer", { sdp })
})
// room:ice-candidate — relays ICE candidates to all other room participants.
socket.on("room:ice-candidate", ({ roomId, candidate }) => {
socket.to(roomId).emit("room:ice-candidate", { candidate })
})
// room:leave — removes the socket from a room. Cleans up the
// in-memory room map and notifies remaining participants. Rooms
// are fully deleted when empty.
socket.on("room:leave", ({ roomId }) => {
socket.leave(roomId)
const participants = rooms.get(roomId)
@@ -218,6 +277,9 @@ io.on("connection", (socket) => {
}
})
// disconnect — fires when the socket loses connection. Cleans up
// the user from all rooms they were in, removes them from the
// online users map, and broadcasts the offline event.
socket.on("disconnect", () => {
for (const [roomId, participants] of rooms) {
const idx = participants.findIndex(p => p.socketId === socket.id)
@@ -238,6 +300,7 @@ io.on("connection", (socket) => {
})
})
// ── Start ─────────────────────────────────────────────────────────
httpServer.listen(PORT, () => {
console.log(`[signaling] server running on port ${PORT}`)
})
+34
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// AI Assistant Page (/(dashboard)/ai-assistant)
// ───────────────────────────────────────────────
// Route: /ai-assistant
// Purpose: AI-powered sales assistant with
// conversational chat and Facebook lead
// scraping by job title. Users select a target
// job, trigger a Facebook scrape, and review
// results in the chat panel.
// Layout: Two-column — main chat area + right
// sidebar with job selector / details.
// ───────────────────────────────────────────────
"use client"
import { useState, useCallback, useRef } from "react"
@@ -5,6 +18,16 @@ import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, ChevronRight } from "lucide-react"
/**
* AIAssistantPage
* ───────────────
* Manages the AI chat + job search workflow.
* Selected job is shown in the sidebar; search
* triggers a Facebook scrape via an external
* scraper service with a 6-minute timeout.
*
* @returns AI assistant layout.
*/
export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
@@ -15,11 +38,13 @@ export default function AIAssistantPage() {
setSelectedJob(job)
}, [])
// ── Facebook lead scraper handler ──
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
setSearching(true)
const keyword = job.keywords?.[0] || job.job_title
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
// 6-minute hard abort, with a "still searching" status message at 45 s
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => {
@@ -33,7 +58,9 @@ export default function AIAssistantPage() {
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
// Format scraped leads as a markdown list for the chat
const leadLines = data.leads
.filter(Boolean)
.map((lead: Record<string, string>, i: number) =>
@@ -61,6 +88,7 @@ export default function AIAssistantPage() {
aiChatRef.current?.fillInput(prompt)
}, [])
// Keep track of last 3 sent prompts for quick reuse
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
@@ -70,7 +98,9 @@ export default function AIAssistantPage() {
return (
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
{/* ── Main chat panel ── */}
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
{/* Header */}
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
@@ -82,6 +112,7 @@ export default function AIAssistantPage() {
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
</div>
</div>
{/* Status indicator */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
@@ -96,6 +127,8 @@ export default function AIAssistantPage() {
</div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
{/* ── Right sidebar: Job selector ── */}
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
<div>
<div className="flex items-center gap-2 mb-3">
@@ -103,6 +136,7 @@ export default function AIAssistantPage() {
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div>
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
{/* Selected job details card */}
{selectedJob && (
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
+46 -10
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// Calendar Page (/(dashboard)/calendar)
// ───────────────────────────────────────────────
// Route: /calendar
// Purpose: Full calendar view with month grid,
// upcoming events sidebar, and CRUD dialogs
// for creating/editing events (call, follow-up,
// website creation). Supports participant and
// developer assignment, client details, notes.
// Layout: Header (nav + "Today" + "New Event") +
// body row: left = month grid, right = upcoming.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
@@ -25,16 +38,19 @@ import {
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
// ── Event type icon mapping ──
const EVENT_ICONS: Record<EventType, React.ElementType> = {
call: Phone,
follow_up: Clock,
website_creation: Globe,
}
/** CSS variable name for an event type (e.g. `--event-call`). */
function eventVars(type: EventType) {
return `--event-${type}`
}
/** Background + text + border style derived from the event type's CSS variable. */
function eventBgStyle(type: EventType): React.CSSProperties {
const v = eventVars(type)
return {
@@ -44,14 +60,17 @@ function eventBgStyle(type: EventType): React.CSSProperties {
}
}
/** A small colored dot using the event type's CSS variable. */
function eventDotStyle(type: EventType): React.CSSProperties {
return { backgroundColor: `hsl(var(--event-${type}))` }
}
/** Get the number of days in a given month (0-indexed). */
function getDaysInMonth(year: number, month: number) {
return new Date(year, month + 1, 0).getDate()
}
/** Get the day-of-week (0=Sun) of the first day of a month. */
function getFirstDayOfMonth(year: number, month: number) {
return new Date(year, month, 1).getDay()
}
@@ -67,6 +86,17 @@ function formatDate(iso: string) {
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
/**
* CalendarPage
* ────────────
* Displays a monthly calendar grid with events
* fetched from the API. Supports creating,
* editing, deleting, and status updates for
* events. Includes an upcoming events sidebar
* (90-day lookahead) and auto-polls every 15 s.
*
* @returns Full calendar layout.
*/
export default function CalendarPage() {
const router = useRouter()
const { user } = useUser()
@@ -286,6 +316,7 @@ export default function CalendarPage() {
}
}
// ── Update event status (complete, cancel, reopen) ──
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
try {
const res = await fetch(`/api/events/${event.id}`, {
@@ -295,6 +326,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
// Merge updated event while preserving loaded relations
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
@@ -303,6 +335,7 @@ export default function CalendarPage() {
}
}
// ── Save participant notes for an event ──
const saveNotes = async (event: CalendarEvent) => {
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
setNotesSaving(true)
@@ -315,6 +348,7 @@ export default function CalendarPage() {
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
// Also update the open detail dialog state
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
@@ -327,6 +361,7 @@ export default function CalendarPage() {
}
}
// ── Delete an event ──
const deleteEvent = async (event: CalendarEvent) => {
try {
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
@@ -340,6 +375,7 @@ export default function CalendarPage() {
}
}
// ── Build array of day cells (null = empty leading days) ──
const calendarDays: (number | null)[] = []
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
@@ -348,7 +384,7 @@ export default function CalendarPage() {
return (
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
{/* Header */}
{/* ── HEADER: month nav + today + new event ── */}
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2.5">
@@ -377,7 +413,7 @@ export default function CalendarPage() {
</Button>
</div>
{/* Body - flex row with independent scroll */}
{/* ── BODY: month grid (left) + upcoming sidebar (right) ── */}
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
{loading ? (
<div className="flex-1 flex items-center justify-center">
@@ -391,9 +427,9 @@ export default function CalendarPage() {
</div>
) : (
<>
{/* Left: Calendar Grid */}
{/* ── CALENDAR GRID (left column) ── */}
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
{/* Stats bar */}
{/* Stats bar: scheduled / completed / total counts */}
<div className="flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
<div className="h-2 w-2 rounded-full bg-primary" />
@@ -412,10 +448,10 @@ export default function CalendarPage() {
</div>
</div>
{/* Calendar grid wrapper - scrollable */}
{/* Scrollable calendar grid wrapper */}
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
<div className="min-w-[600px]">
{/* Day headers */}
{/* Day-of-week column headers (SunSat) */}
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
{DAYS.map((d, i) => (
<div key={d} className={cn(
@@ -428,7 +464,7 @@ export default function CalendarPage() {
))}
</div>
{/* Day cells */}
{/* Calendar day cells */}
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
@@ -502,7 +538,7 @@ export default function CalendarPage() {
</div>
</div>
{/* Right: Upcoming sidebar */}
{/* ── UPCOMING EVENTS SIDEBAR (right column) ── */}
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
<div className="flex items-center justify-between shrink-0">
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
@@ -612,7 +648,7 @@ export default function CalendarPage() {
)}
</div>
{/* Create/Edit Dialog */}
{/* ── CREATE / EDIT EVENT DIALOG ── */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
@@ -768,7 +804,7 @@ export default function CalendarPage() {
</DialogContent>
</Dialog>
{/* Event Detail Dialog */}
{/* ── EVENT DETAIL DIALOG ── */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
+65 -10
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Chats Page (/(dashboard)/chats)
// ───────────────────────────────────────────────
// Route: /chats
// Purpose: Full-featured real-time messaging with
// voice notes, file attachments, GIFs, stickers,
// message editing/deletion/forwarding, read
// receipts, emoji picker, inline image/avatar
// previews, voice calls, and event scheduling.
// Layout: Resizable two-panel layout — left:
// conversation list; right: chat area (header,
// messages, input bar).
// ───────────────────────────────────────────────
"use client"
import { useState, useRef, useCallback, useEffect, useMemo } from "react"
@@ -37,10 +51,24 @@ import { io, Socket } from "socket.io-client"
import VoiceCallModal from "@/components/chats/voice-call-modal"
import MediaPicker from "@/components/chats/media-picker"
/** Module-level refs to prevent multiple audio instances from playing simultaneously. */
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
const previewAnimRef: { current: number } = { current: 0 }
// ──────────────────────────────────────────────
// VoiceMessagePlayer
// ──────────────────────────────────────────────
/**
* Renders a voice note message with:
* - Play/pause toggle
* - Animated waveform bars
* - Seekable progress slider
* - Elapsed / total duration
* - Replay button
*
* Only one audio plays globally via activeAudioRef.
*/
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
const [playing, setPlaying] = useState(false)
const [current, setCurrent] = useState(0)
@@ -49,6 +77,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
const animRef = useRef<number>(0)
const progRef = useRef<HTMLInputElement>(null)
// Load actual duration from metadata (fallback to initialDuration)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
@@ -59,6 +88,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
}, [src])
// Animation frame loop — updates current time while playing
useEffect(() => {
if (!playing) { cancelAnimationFrame(animRef.current); return }
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
@@ -70,6 +100,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
if (!audioRef.current) return
if (playing) { audioRef.current.pause(); setPlaying(false) }
else {
// Pause any other playing voice note
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
activeAudioRef.current = audioRef.current
audioRef.current.play(); setPlaying(true)
@@ -91,6 +122,7 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
// Generate 28 pseudo-random waveform bar heights using sine combinations
const barCount = 28
const waveform = Array.from({ length: barCount }, (_, i) => {
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
@@ -122,9 +154,24 @@ function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; init
)
}
/** Max conversations to keep in client-side cache (LRU). */
const MAX_CACHED_CONVERSATIONS = 5
const otherParticipant = (conv: any) => conv.otherUser
// ──────────────────────────────────────────────
// ChatsPage — Main Chat Application Component
// ──────────────────────────────────────────────
/**
* Full-featured chat interface. Manages:
* - Conversation list with search / create
* - Real-time message polling (4 s) + Socket.io
* - Voice recording, file attachments, GIF/stickers
* - Message edit, delete, forward, reply
* - Resizable panel, read receipts, emoji picker
* - Voice call modal + event scheduling
*
* @returns Chat layout or null if no user.
*/
export default function ChatsPage() {
const { theme } = useTheme()
const { user } = useUser()
@@ -187,8 +234,10 @@ export default function ChatsPage() {
const socketRef = useRef<Socket | null>(null)
const isDeletingRef = useRef(false)
/** Check if a string consists only of emoji characters. */
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
/** Parse message content for preview display in conversation list. */
const formatPreviewContent = (content: string) => {
if (!content?.startsWith("{")) return content
try {
@@ -200,6 +249,7 @@ const formatPreviewContent = (content: string) => {
return content
}
/** Auto-resize the textarea based on its scroll height. */
const autoResizeTextarea = useCallback(() => {
const ta = textareaRef.current
if (!ta) return
@@ -209,6 +259,7 @@ const formatPreviewContent = (content: string) => {
if (!user) return null
// ── Memoized derived data ──
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
const filteredConversations = useMemo(() => {
@@ -794,11 +845,12 @@ const formatPreviewContent = (content: string) => {
return (
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */}
{/* ── CONVERSATIONS LIST (left panel) ── */}
<div
className="flex flex-col border-r shrink-0 overflow-hidden"
style={{ width: panelWidth }}
>
{/* Search header */}
<div className="p-4 border-b space-y-3">
<h2 className="text-lg font-semibold">Chats</h2>
<div className="relative">
@@ -806,6 +858,7 @@ const formatPreviewContent = (content: string) => {
<Input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="Search conversations..." className="h-9 pl-9" />
</div>
</div>
{/* Scrollable conversation list */}
<ScrollArea className="flex-1">
{filteredConversations.map((conv) => {
const person = otherParticipant(conv)
@@ -897,7 +950,7 @@ const formatPreviewContent = (content: string) => {
</ScrollArea>
</div>
{/* Resize handle */}
{/* ── RESIZE HANDLE ── */}
<div
className="w-1.5 cursor-col-resize shrink-0 relative group hover:bg-primary/20 transition-colors"
onMouseDown={handleResizeStart}
@@ -905,10 +958,10 @@ const formatPreviewContent = (content: string) => {
<div className="absolute inset-y-0 -left-1 -right-1" />
</div>
{/* Chat area - right panel */}
{/* ── CHAT AREA (right panel) ── */}
{conversation ? (
<div className="flex-1 flex flex-col min-w-0">
{/* Chat header */}
{/* Chat header: avatar, name, actions */}
<div className="flex items-center justify-between gap-4 px-6 h-16 border-b shrink-0">
<div className="flex items-center gap-3 min-w-0">
<Avatar className="h-9 w-9 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(otherParticipant(conversation).avatar)}>
@@ -951,7 +1004,7 @@ const formatPreviewContent = (content: string) => {
</div>
</div>
{/* Messages */}
{/* ── MESSAGES ── */}
<div className="flex-1 overflow-y-auto p-6" style={{ scrollbarWidth: "thin", scrollbarColor: "hsl(var(--muted-foreground) / 0.3) transparent" }}>
<div className="space-y-4 min-h-0">
{messages.map((msg) => {
@@ -1105,7 +1158,7 @@ const formatPreviewContent = (content: string) => {
</div>
</div>
{/* Input */}
{/* ── INPUT BAR ── */}
<div className="p-4 border-t shrink-0 space-y-2">
{attachments.length > 0 && (
<div className="flex flex-wrap gap-2">
@@ -1234,9 +1287,11 @@ const formatPreviewContent = (content: string) => {
</div>
)}
{/* Resize overlay */}
{/* Resize drag overlay (captures mouse events during resize) */}
{isResizing && <div className="fixed inset-0 z-50 cursor-col-resize" />}
{/* ── DIALOGS ── */}
{/* Report dialog */}
<Dialog open={reportDialogOpen} onOpenChange={setReportDialogOpen}>
<DialogContent className="sm:max-w-md">
@@ -1255,7 +1310,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Image preview dialog */}
{/* Image preview dialog (full-size) */}
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
<DialogTitle className="sr-only">Image preview</DialogTitle>
@@ -1269,7 +1324,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Avatar preview dialog */}
{/* ── Avatar preview dialog ── */}
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
{previewAvatarUrl && (
@@ -1282,7 +1337,7 @@ const formatPreviewContent = (content: string) => {
</DialogContent>
</Dialog>
{/* Forward dialog */}
{/* ── Forward message dialog ── */}
<Dialog open={forwardDialogOpen} onOpenChange={setForwardDialogOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
+31
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Dashboard Page (/(dashboard)/dashboard)
// ───────────────────────────────────────────────
// Route: /dashboard
// Purpose: Main overview page showing pipeline
// stats, trend sparklines, status/leads-per-month
// charts, and a recent-leads table. Polls the
// API every 30 seconds for live updates.
// Layout: Uses PageHeader + period selector,
// 6 stat cards, 2 charts, and a table.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useRef } from "react"
@@ -26,12 +38,23 @@ import {
import { DashboardStats } from "@/types"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* DashboardPage
* ─────────────
* Fetches dashboard stats for a selected period
* and renders stat cards, charts, and a recent-
* leads table. Includes auto-polling every 30 s.
*
* @returns Full dashboard layout with loading
* skeleton states.
*/
export default function DashboardPage() {
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
// ── Fetch dashboard stats from API ──
async function fetchStats(p: string) {
try {
const res = await fetch(`/api/dashboard?period=${p}`)
@@ -44,6 +67,7 @@ export default function DashboardPage() {
}
}
// ── Initial fetch + 30-second polling ──
useEffect(() => {
fetchStats(period)
pollingRef.current = setInterval(() => fetchStats(period), 30000)
@@ -52,6 +76,7 @@ export default function DashboardPage() {
}
}, [period])
// ── Build stat-card configs from API response ──
const statCards = stats
? [
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
@@ -66,6 +91,7 @@ export default function DashboardPage() {
return (
<div className="space-y-6 relative">
<div className="relative z-[1] space-y-6">
{/* ── Header with period filter ── */}
<div>
<PageHeader
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
@@ -86,6 +112,7 @@ export default function DashboardPage() {
</PageHeader>
</div>
{/* ── Pipeline stat cards (or skeletons while loading) ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
@@ -97,6 +124,7 @@ export default function DashboardPage() {
}
</div>
{/* ── Analytics charts ── */}
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
@@ -104,8 +132,11 @@ export default function DashboardPage() {
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
</div>
{/* ── Recent leads table ── */}
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* ── Themed decorative overlay (Spidey theme) ── */}
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
+25
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Emails Page (/(dashboard)/emails)
// ───────────────────────────────────────────────
// Route: /emails
// Purpose: Admin-only email log viewer. Lists
// all sent emails with subject, recipient,
// and timestamp. Extracts "With:" metadata
// from the email body text.
// Layout: Simple list with header + description.
// Access restricted to admin / super_admin.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -5,11 +17,21 @@ import { useRouter } from "next/navigation"
import { useUser } from "@/providers/user-provider"
import { Mail, Clock } from "lucide-react"
/**
* EmailsPage
* ──────────
* Dispatched-email log. Only available to admin
* and super_admin roles. Redirects unauthorized
* users back to /dashboard or /login.
*
* @returns Email list UI or null if unauthorized.
*/
export default function EmailsPage() {
const router = useRouter()
const { user } = useUser()
const [emails, setEmails] = useState<any[]>([])
// ── Auth guard + fetch emails ──
useEffect(() => {
if (!user) { router.push("/login"); return }
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
@@ -24,11 +46,14 @@ export default function EmailsPage() {
<p className="text-sm text-muted-foreground mb-6">
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
</p>
{/* ── Email list or empty state ── */}
{emails.length === 0 ? (
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
) : (
<div className="space-y-2">
{emails.map((e: any) => {
// Extract "With:" metadata from email body for display
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
const withName = withMatch ? withMatch[1].trim() : null
return (
+36
View File
@@ -1,3 +1,17 @@
// ───────────────────────────────────────────────
// Lead Details Page (/(dashboard)/leads/[id])
// ───────────────────────────────────────────────
// Route: /leads/:id
// Purpose: Full detail view for a single lead.
// Displays company/contact info, notes timeline,
// activity log, and quick actions. Supports
// inline status changes with optimistic UI.
// Layout: 3-column grid — 2-col main area
// (details card + notes) + 1-col sidebar
// (activity + quick actions). Uses framer-motion
// for staggered entrance animations.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -20,17 +34,31 @@ import {
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
import { Lead, Note } from "@/types"
/**
* LeadDetailsPage
* ───────────────
* Fetches lead + notes in parallel on mount.
* Handles loading, not-found, and error states.
* Status select uses optimistic updates with
* rollback on failure.
*
* @param params - Next.js route params (Promise
* unwrapped via React.use()).
* @returns Lead detail layout.
*/
export default function LeadDetailsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const [lead, setLead] = useState<Lead | null>(null)
const [leadNotes, setLeadNotes] = useState<Note[]>([])
const [loading, setLoading] = useState(true)
// ── Fetch notes for this lead ──
const fetchNotes = useCallback(async () => {
const notesRes = await fetch(`/api/leads/${id}/notes`)
if (notesRes.ok) setLeadNotes(await notesRes.json())
}, [id])
// ── Initial data load ──
useEffect(() => {
async function fetchData() {
try {
@@ -47,6 +75,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
fetchData()
}, [id, fetchNotes])
// ── Loading state ──
if (loading) {
return (
<div className="flex items-center justify-center h-[60vh]">
@@ -55,6 +84,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
)
}
// ── Not-found state ──
if (!lead) {
return (
<div className="flex items-center justify-center h-[60vh]">
@@ -74,6 +104,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
return (
<div className="space-y-6">
{/* ── Back link ── */}
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -82,6 +113,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
Back to leads
</Link>
{/* ── Header: company name, status badge, status select, edit button ── */}
<PageHeader
title={
<div className="flex items-center gap-3">
@@ -94,6 +126,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
<Select
value={lead.status}
onValueChange={async (v) => {
// Optimistic update — revert on fail
const previousStatus = lead.status
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
try {
@@ -125,7 +158,9 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</Button>
</PageHeader>
{/* ── Main content grid ── */}
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Left: Details + Notes ── */}
<div className="lg:col-span-2 space-y-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
@@ -152,6 +187,7 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
</motion.div>
</div>
{/* ── Right sidebar: Activity + Quick Actions ── */}
<div className="space-y-6">
<motion.div
initial={{ opacity: 0, x: 20 }}
+30
View File
@@ -1,3 +1,16 @@
// ───────────────────────────────────────────────
// Create Lead Page (/(dashboard)/leads/new)
// ───────────────────────────────────────────────
// Route: /leads/new
// Purpose: Form to create a new lead. Uses
// react-hook-form + zod for validation.
// On success, fires a notification and
// redirects to the leads list.
// Layout: Back link, heading, Card with form
// fields in a 2-col grid + textarea + user
// assignment select.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -30,6 +43,7 @@ import { User } from "@/types"
import { useNotifications } from "@/providers/notification-provider"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
/** Zod schema for lead creation form validation. */
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"),
@@ -43,12 +57,22 @@ const leadFormSchema = z.object({
type LeadFormValues = z.infer<typeof leadFormSchema>
/**
* CreateLeadPage
* ──────────────
* Zod-validated lead creation form. Fetches the
* user list for lead assignment on mount. On
* submit POSTs to /api/leads and navigates back.
*
* @returns Lead creation form layout.
*/
export default function CreateLeadPage() {
const router = useRouter()
const { addNotification } = useNotifications()
const [saving, setSaving] = useState(false)
const [users, setUsers] = useState<User[]>([])
// ── Fetch available users for assignment ──
useEffect(() => {
fetch("/api/users")
.then((r) => r.json())
@@ -70,9 +94,11 @@ export default function CreateLeadPage() {
},
})
// ── Submit handler ──
async function onSubmit(values: LeadFormValues) {
setSaving(true)
try {
// Transform "none" sentinel value to null
const payload = {
...values,
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
@@ -84,6 +110,7 @@ export default function CreateLeadPage() {
})
if (!res.ok) throw new Error("Failed to create lead")
const data = await res.json()
// Notify other parts of the app
addNotification("lead_created", "New Lead Created", `${values.companyName}${values.contactName}`, `/leads/${data.id}`)
router.push("/leads")
} catch (e) {
@@ -95,6 +122,7 @@ export default function CreateLeadPage() {
return (
<div className="space-y-6">
{/* ── Back navigation ── */}
<Link
href="/leads"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
@@ -108,6 +136,7 @@ export default function CreateLeadPage() {
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
</div>
{/* ── Lead form ── */}
<Card>
<CardContent className="pt-6">
<Form {...form}>
@@ -256,6 +285,7 @@ export default function CreateLeadPage() {
</FormItem>
)}
/>
{/* ── Submit / Cancel buttons ── */}
<div className="flex items-center gap-3 pt-2">
<Button type="submit" disabled={saving}>
{saving ? "Saving..." : "Create Lead"}
+23
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Leads List Page (/(dashboard)/leads)
// ───────────────────────────────────────────────
// Route: /leads
// Purpose: List, search, filter, and navigate to
// leads. Supports status/period filters and a
// text search. "Create" button navigates to
// /leads/new.
// Layout: PageHeader + toolbar (search + filters)
// + LeadsTable.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect } from "react"
@@ -7,6 +19,15 @@ import { LeadsTable } from "@/components/leads/leads-table"
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
import { Lead } from "@/types"
/**
* LeadsPage
* ─────────
* Fetches leads from the API whenever search,
* status, or period filters change. Passes data
* and loading state to the table and toolbar.
*
* @returns Leads list layout.
*/
export default function LeadsPage() {
const router = useRouter()
const [search, setSearch] = useState("")
@@ -15,6 +36,7 @@ export default function LeadsPage() {
const [leadsData, setLeadsData] = useState<Lead[]>([])
const [loading, setLoading] = useState(true)
// ── Fetch leads with current filters ──
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
@@ -38,6 +60,7 @@ export default function LeadsPage() {
description="Manage and track your sales leads"
/>
{/* ── Toolbar + Table ── */}
<div className="rounded-lg border bg-card">
<div className="p-4">
<LeadsTableToolbar
+28
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Profile Page (/(dashboard)/profile)
// ───────────────────────────────────────────────
// Route: /profile
// Purpose: View and edit the current user's
// profile — avatar upload, personal info,
// role, join date, and status.
// Layout: Two-column grid — left card with avatar
// + metadata badges, right card with account
// details in a 2-column grid.
// ───────────────────────────────────────────────
"use client"
import { useRef } from "react"
@@ -9,19 +21,31 @@ import { useUser } from "@/providers/user-provider"
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
import { toast } from "sonner"
/**
* ProfilePage
* ───────────
* Displays the currently logged-in user's profile.
* Allows avatar upload (PNG/JPEG) via a hidden
* file input with a hover-triggered camera overlay.
*
* @returns Profile layout or null if no user.
*/
export default function ProfilePage() {
const { user, updateAvatar } = useUser()
if (!user) return null
const fileInputRef = useRef<HTMLInputElement>(null)
// ── Avatar upload handler ──
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
// Validate file type — only PNG and JPEG accepted
const validTypes = ["image/png", "image/jpeg"]
if (!validTypes.includes(file.type)) {
toast.error("Only PNG and JPEG files are allowed")
return
}
// Read as data URL, then POST to API
const reader = new FileReader()
reader.onload = async () => {
const dataUrl = reader.result as string
@@ -51,8 +75,10 @@ export default function ProfilePage() {
<PageHeader title="Profile" description="Your account information" />
<div className="grid gap-6 lg:grid-cols-3">
{/* ── Left column: Avatar + quick info ── */}
<Card className="lg:col-span-1">
<CardContent className="flex flex-col items-center pt-8">
{/* Avatar with hover-to-upload overlay */}
<div className="relative">
<Avatar className="h-24 w-24">
<AvatarImage src={user.avatar} />
@@ -77,6 +103,7 @@ export default function ProfilePage() {
<Badge variant="secondary" className="mt-1 capitalize">
{user.role}
</Badge>
{/* Metadata rows */}
<div className="mt-6 flex w-full flex-col gap-3 text-sm">
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
<Mail className="h-4 w-4 text-muted-foreground" />
@@ -100,6 +127,7 @@ export default function ProfilePage() {
</CardContent>
</Card>
{/* ── Right column: Full account details ── */}
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
+31 -9
View File
@@ -1,3 +1,15 @@
// ───────────────────────────────────────────────
// Settings Page (/(dashboard)/settings)
// ───────────────────────────────────────────────
// Route: /settings
// Purpose: Tabbed settings panel covering company
// info, user preferences, theme choices, and
// notification configuration.
// Layout: PageHeader + <Tabs> with 4 tab panels.
// Each tab renders a dedicated settings form
// component.
// ───────────────────────────────────────────────
"use client"
import { PageHeader } from "@/components/shared/page-header"
@@ -8,14 +20,23 @@ import { ThemeSettings } from "@/components/settings/theme-settings"
import { NotificationSettings } from "@/components/settings/notification-settings"
import { Building2, User, Palette, Bell } from "lucide-react"
export default function SettingsPage() {
const tabs = [
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
]
/** Tab configuration: value, label, icon, and the component to render. */
const settingsTabs = [
{ value: "company", label: "Company", icon: Building2, component: CompanySettingsForm },
{ value: "preferences", label: "Preferences", icon: User, component: UserPreferencesForm },
{ value: "theme", label: "Theme", icon: Palette, component: ThemeSettings },
{ value: "notifications", label: "Notifications", icon: Bell, component: NotificationSettings },
]
/**
* SettingsPage
* ────────────
* Renders a tabbed settings interface. Each tab
* dynamically renders its associated form component.
*
* @returns Settings layout with navigation tabs.
*/
export default function SettingsPage() {
return (
<div className="space-y-4">
<PageHeader
@@ -23,16 +44,17 @@ export default function SettingsPage() {
description="Manage your CRM configuration and preferences"
/>
{/* ── Tabbed settings sections ── */}
<Tabs defaultValue="company" className="space-y-6">
<TabsList>
{tabs.map((tab) => (
{settingsTabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value} className="gap-2">
<tab.icon className="h-4 w-4" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab) => (
{settingsTabs.map((tab) => (
<TabsContent key={tab.value} value={tab.value}>
<tab.component />
</TabsContent>
+29
View File
@@ -1,3 +1,14 @@
// ───────────────────────────────────────────────
// Users Page (/(dashboard)/users)
// ───────────────────────────────────────────────
// Route: /users
// Purpose: Team management — list, search, and
// create users. Shows summary stats cards and
// a searchable table.
// Layout: PageHeader + stat grid + search bar +
// UsersTable + inline UserFormDialog.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -10,6 +21,16 @@ import { useUser } from "@/providers/user-provider"
import { Plus, Users as UsersIcon, Search } from "lucide-react"
import type { User } from "@/types"
/**
* UsersPage
* ─────────
* Fetches all users on mount, computes summary
* stats (total / active / admins / sales), and
* renders a searchable table. "Add User" button
* opens a modal dialog for creation.
*
* @returns Users management layout.
*/
export default function UsersPage() {
const { user } = useUser()
const [users, setUsers] = useState<User[]>([])
@@ -17,6 +38,7 @@ export default function UsersPage() {
const [createOpen, setCreateOpen] = useState(false)
const [search, setSearch] = useState("")
// ── Fetch all users ──
const fetchUsers = useCallback(async () => {
try {
const res = await fetch("/api/users")
@@ -35,6 +57,7 @@ export default function UsersPage() {
fetchUsers()
}, [fetchUsers])
// ── Derived stats ──
const activeUsers = users.filter((u) => u.active !== false)
const admins = users.filter((u) => u.role === "admin")
@@ -45,6 +68,7 @@ export default function UsersPage() {
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
]
// ── Client-side search filter by name/email ──
const filtered = users.filter((u) => {
if (!search) return true
const q = search.toLowerCase()
@@ -53,6 +77,7 @@ export default function UsersPage() {
return (
<div className="space-y-4">
{/* ── Header with "Add User" action ── */}
<PageHeader
title="Users"
description="Manage team members and their roles"
@@ -63,6 +88,7 @@ export default function UsersPage() {
</Button>
</PageHeader>
{/* ── Summary stat cards ── */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-6">
{stats.map((s) => (
<div key={s.label} className="rounded-lg border bg-card p-4">
@@ -81,6 +107,7 @@ export default function UsersPage() {
))}
</div>
{/* ── Search input ── */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -91,10 +118,12 @@ export default function UsersPage() {
/>
</div>
{/* ── Users data table ── */}
<div className="rounded-lg border bg-card">
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
</div>
{/* ── Create-user dialog ── */}
<UserFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
+10
View File
@@ -1,7 +1,16 @@
// ── AI: Chat ─────────────────────────────────────────────────────────────────
// POST /api/ai/chat — Send a message to the AI assistant and get a response
//
// Auth: authenticated
// Body: { message: string }
// Response: { response: string }
import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -14,6 +23,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
// Extract JWT from session cookie to forward to the AI service for auth
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
+14
View File
@@ -1,9 +1,20 @@
// ── AI: Giphy Integration ────────────────────────────────────────────────────
// GET /api/ai/giphy — Proxy for GIPHY API (search or trending)
//
// Auth: authenticated
// Query params: type=search|trending, q=search query, offset, limit
// Returns a curated subset of GIF fields suitable for chat/UI integration.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── Constants ────────────────────────────────────────────────────────────────
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -21,6 +32,7 @@ export async function GET(request: NextRequest) {
const offset = parseInt(searchParams.get("offset") || "0", 10)
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
// Route to search or trending endpoint based on type param
let url: string
if (type === "search" && query) {
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
@@ -36,6 +48,8 @@ export async function GET(request: NextRequest) {
}
const data = await res.json()
// Normalize the response to a minimal set of fields used by the UI
const gifs = (data.data || []).map((gif: any) => ({
id: gif.id,
title: gif.title,
+8
View File
@@ -1,7 +1,15 @@
// ── AI: Job Listing ──────────────────────────────────────────────────────────
// GET /api/ai/jobs — Fetch AI-related job postings
//
// Auth: authenticated; role must be sales, admin, or super_admin
// Returns an empty array on failure (graceful degradation).
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { fetchJobs } from "@/lib/ai"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+17
View File
@@ -1,3 +1,9 @@
// ── Auth: Login ──────────────────────────────────────────────────────────────
// POST /api/auth/login
// Authenticates a user with email/username + password and returns a JWT session
// cookie. Supports account lockout, credential validation, and login audit
// logging. Returns user data on success, error details on failure.
import { NextRequest } from "next/server"
import {
comparePassword,
@@ -13,6 +19,7 @@ import {
SESSION_COOKIE,
} from "@/lib/auth"
// ── Helpers ──────────────────────────────────────────────────────────────────
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
@@ -21,12 +28,15 @@ function jsonResponse(data: unknown, status: number) {
})
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
const credential = email || username
// Validate that both credential and password are present
if (!credential || !password) {
return jsonResponse(
{ error: "Email/Username and password are required." },
@@ -34,6 +44,7 @@ export async function POST(request: NextRequest) {
)
}
// Reject empty strings (whitespace-only credentials)
if (credential.trim().length === 0 || password.trim().length === 0) {
return jsonResponse(
{ error: "Credentials cannot be empty." },
@@ -41,6 +52,7 @@ export async function POST(request: NextRequest) {
)
}
// Extract client IP from proxy headers for audit logging
const ipAddress =
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
@@ -58,6 +70,7 @@ export async function POST(request: NextRequest) {
dbUser = await getUserByUsername(credential)
}
// If user does not exist, log attempt and return generic error
if (!dbUser) {
await recordLoginAttempt(
null,
@@ -73,6 +86,7 @@ export async function POST(request: NextRequest) {
)
}
// Check if account is temporarily locked due to too many failed attempts
const lockStatus = await isAccountLocked(dbUser)
if (lockStatus.locked) {
await recordLoginAttempt(
@@ -89,6 +103,7 @@ export async function POST(request: NextRequest) {
)
}
// Verify password hash against stored hash
const valid = await comparePassword(password, dbUser.password_hash)
if (!valid) {
await incrementFailedAttempts(dbUser.id)
@@ -106,6 +121,7 @@ export async function POST(request: NextRequest) {
)
}
// Successful login: reset failure counter, log success, create session
await resetFailedAttempts(dbUser.id)
await recordLoginAttempt(
dbUser.id,
@@ -120,6 +136,7 @@ export async function POST(request: NextRequest) {
const user = mapDbUserToSessionUser(dbUser)
// Set HttpOnly session cookie with configurable Secure flag in production
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
+8
View File
@@ -1,7 +1,15 @@
// ── Auth: Logout ─────────────────────────────────────────────────────────────
// POST /api/auth/logout
// Clears the session cookie by setting Max-Age=0, effectively logging the
// user out. Stateless — no server-side session invalidation needed.
import { SESSION_COOKIE } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST() {
try {
// Overwrite cookie with an immediate expiry (Max-Age=0)
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
+8
View File
@@ -1,6 +1,14 @@
// ── Auth: Current User ──────────────────────────────────────────────────────
// GET /api/auth/me
// Returns the authenticated user's profile from the current session. Used by
// the front-end to validate tokens and hydrate user context on load.
// Returns 401 if no valid session exists.
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+15
View File
@@ -1,3 +1,12 @@
// ── Auth: Password Recovery ──────────────────────────────────────────────────
// POST /api/auth/recover
// Super-admin only. Decrypts and returns the plaintext password for a given
// user. Used as an admin recovery tool, not a self-service reset flow.
//
// Auth: super_admin only
// Body: { userId: string }
// Response: { user: ..., password: plaintext }
import { NextRequest, NextResponse } from "next/server"
import {
getSessionUser,
@@ -6,12 +15,15 @@ import {
} from "@/lib/auth"
import { query } from "@/lib/db"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
if (!sessionUser) {
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
}
// Only super_admin is allowed to recover other users' passwords
if (sessionUser.role !== "super_admin") {
return NextResponse.json({ error: "Only SUPER_ADMIN can recover passwords." }, { status: 403 })
}
@@ -28,6 +40,7 @@ export async function POST(request: NextRequest) {
await setSessionContext(sessionUser.id, ipAddress)
// Fetch the target user (excluding soft-deleted records)
const result = await query(
`SELECT id, username, email, first_name, last_name, password_encrypted
FROM users WHERE id = $1 AND deleted_at IS NULL`,
@@ -39,10 +52,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "User not found." }, { status: 404 })
}
// Guard against missing encrypted password column
if (!user.password_encrypted) {
return NextResponse.json({ error: "No encrypted password stored for this user." }, { status: 404 })
}
// Decrypt using the master key; failure suggests key rotation or corruption
const plaintextPassword = await decryptPassword(user.password_encrypted)
if (!plaintextPassword) {
return NextResponse.json({ error: "Failed to decrypt password. Master key may have changed." }, { status: 500 })
+10
View File
@@ -1,7 +1,15 @@
// ── Bug Reports: Single Report ───────────────────────────────────────────────
// PATCH /api/bug-reports/[id] — Update bug report status, assignment, or notes
//
// Auth: admin/super_admin only
// Supports partial updates for: status, assigned_to, resolution_notes
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser, setSessionContext } from "@/lib/auth"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params: routeParams }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
@@ -27,11 +35,13 @@ export async function PATCH(request: NextRequest, { params: routeParams }: { par
return NextResponse.json({ error: "Invalid status." }, { status: 400 })
}
// Verify the bug report exists before updating
const existing = await query("SELECT id, status FROM bug_reports WHERE id = $1", [id])
if (existing.rows.length === 0) {
return NextResponse.json({ error: "Bug report not found." }, { status: 404 })
}
// Build dynamic SET clause for partial updates
const updates: string[] = []
const values: unknown[] = []
let paramIndex = 1
+12
View File
@@ -1,7 +1,16 @@
// ── Bug Reports: Collection ──────────────────────────────────────────────────
// POST /api/bug-reports — Submit a new bug report (any authenticated user)
// GET /api/bug-reports — List bug reports with filters (admin/super_admin only)
//
// Auth: POST = authenticated; GET = admin/super_admin
// GET supports: status, severity, limit, offset
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -38,6 +47,8 @@ export async function POST(request: NextRequest) {
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -54,6 +65,7 @@ export async function GET(request: NextRequest) {
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Build dynamic SQL with optional status/severity filters
let sql = `SELECT br.id, br.title, br.description, br.severity, br.page_url,
br.screenshot_url, br.status, br.resolution_notes,
br.created_at, br.updated_at,
@@ -1,3 +1,10 @@
// ── Messages: Single Message ─────────────────────────────────────────────────
// DELETE /api/conversations/[id]/messages/[messageId]
// — Delete a specific message (soft-delete) owned by the current user.
// — Handles cleanup of associated voice note audio files.
//
// Auth: authenticated; user must be a participant and the message sender
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
@@ -5,6 +12,8 @@ import { unlink } from "node:fs/promises"
import { join } from "node:path"
import { existsSync } from "node:fs"
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string; messageId: string }> },
@@ -1,9 +1,19 @@
// ── Messages: Collection ─────────────────────────────────────────────────────
// GET /api/conversations/[id]/messages — List messages in a conversation
// POST /api/conversations/[id]/messages — Send a new message
// DELETE /api/conversations/[id]/messages — Delete own message (by messageId in body)
// PATCH /api/conversations/[id]/messages — Edit own message content
//
// Auth: authenticated; user must be a participant in the conversation
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -37,6 +47,7 @@ export async function GET(
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
const msgParams: any[] = [id]
// Optional cursor-based pagination: fetch messages before a given timestamp
if (before) {
msgSql += ` AND m.created_at < $2`
msgParams.push(before)
@@ -46,6 +57,7 @@ export async function GET(
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
msgParams.push(limit, offset)
// Fetch messages and other participant's last_read_at in parallel
const [msgResult, otherReadResult] = await Promise.all([
query(msgSql, msgParams),
query(
@@ -59,6 +71,7 @@ export async function GET(
? new Date(otherReadResult.rows[0].last_read_at).getTime()
: 0
// Mark outgoing messages as "read" if the other participant has seen them
const messages = msgResult.rows.map((row: any) => ({
id: row.id,
conversationId: id,
@@ -80,6 +93,8 @@ export async function GET(
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -104,7 +119,7 @@ export async function POST(
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
}
// Server-side blocked code extension check
// Server-side check: reject messages with blocked code file extensions
try {
const parsed = JSON.parse(content)
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
@@ -123,6 +138,7 @@ export async function POST(
[id, user.id, content.trim()],
)
// Bump the conversation's updated_at timestamp
await query(
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
[id],
@@ -131,6 +147,7 @@ export async function POST(
const msg = result.rows[0]
const senderName = `${user.firstName} ${user.lastName}`
// Notify the other participant about the new message
const otherResult = await query(
`SELECT user_id FROM conversation_participants
WHERE conversation_id = $1 AND user_id != $2`,
@@ -164,6 +181,12 @@ export async function POST(
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Formats a Date to a short time string.
* Shows time only for today, date + time for older messages.
*/
function formatTime(date: Date): string {
const now = new Date()
const isToday = date.toDateString() === now.toDateString()
@@ -174,6 +197,8 @@ function formatTime(date: Date): string {
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -187,6 +212,7 @@ export async function DELETE(
const messageId = body.messageId
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
// Only the sender can soft-delete their own message
const result = await query(
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
[messageId, user.id, id],
@@ -202,6 +228,8 @@ export async function DELETE(
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -218,6 +246,7 @@ export async function PATCH(
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
}
// Only the sender can edit their own message; also check it's not deleted
const result = await query(
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
[newContent.trim(), messageId, user.id, id],
@@ -1,7 +1,16 @@
// ── Conversations: Mark as Read ─────────────────────────────────────────────
// POST /api/conversations/[id]/read
// — Updates the current user's last_read_at timestamp for the conversation.
// — Also marks all related (unread) notifications for this conversation as read.
//
// Auth: authenticated; user must be a participant
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
@@ -12,6 +21,7 @@ export async function POST(
const { id } = await params
// Update the participant's last_read_at to now
await query(
`UPDATE conversation_participants
SET last_read_at = NOW()
@@ -19,6 +29,7 @@ export async function POST(
[id, user.id],
)
// Clear any existing notifications for this conversation
await query(
`UPDATE notifications SET is_read = TRUE
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
+25 -2
View File
@@ -1,13 +1,26 @@
// ── Conversations: Collection ────────────────────────────────────────────────
// GET /api/conversations — List the current user's conversations
// POST /api/conversations — Start a new conversation with another user
//
// Auth: authenticated
// GET returns up to 50 conversations with last message preview and unread count.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query, transaction } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Fetch all conversations the user participates in, with:
// - other participant's info
// - last message content/time via LATERAL join
// - unread count (messages after last_read_at, excluding own messages)
const result = await query(
`SELECT
c.id,
@@ -33,8 +46,8 @@ export async function GET() {
WHERE c.id IN (
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
)
ORDER BY c.updated_at DESC
LIMIT 50`,
ORDER BY c.updated_at DESC
LIMIT 50`,
[user.id],
)
@@ -59,6 +72,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -73,6 +88,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
}
// Check if a conversation between these two users already exists
const existing = await query(
`SELECT c.id FROM conversations c
JOIN conversation_participants cp1 ON cp1.conversation_id = c.id AND cp1.user_id = $1
@@ -85,12 +101,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ conversationId: existing.rows[0].id })
}
// Create new conversation + participants in a single transaction
const result = await transaction(async (client) => {
const convResult = await client.query(
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
)
const conversationId = convResult.rows[0].id
// Insert both participants with immediate last_read_at (auto-read)
await client.query(
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
[conversationId, user.id, userId],
@@ -128,6 +146,11 @@ export async function POST(request: NextRequest) {
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Converts a Date to a relative time string (e.g., "5m ago", "2h ago").
*/
function timeAgo(date: Date): string {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
if (seconds < 60) return "now"
+32 -4
View File
@@ -1,8 +1,21 @@
// ── Dashboard ────────────────────────────────────────────────────────────────
// GET /api/dashboard?period=6months&year=2025
// — Returns aggregated stats, trends, monthly breakdown, and recent leads.
//
// Auth: authenticated; non-admin users scoped to their own leads
//
// Periods: 7days, 30days, 6months (default), 12months, or a specific year
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Returns the { start, end } Date range for a named period.
*/
function getPeriodDateRange(period: string): { start: Date; end: Date } {
const end = new Date()
let start: Date
@@ -20,6 +33,9 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } {
return { start, end }
}
/**
* Returns the range for the *previous* period of the same length.
*/
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
const end = new Date(currentStart)
const diff = end.getTime() - currentStart.getTime()
@@ -34,6 +50,9 @@ const periodLabels: Record<string, string> = {
"12months": "Last 12 months",
}
/**
* Maps DB stage name to client-facing status string.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -48,12 +67,16 @@ function stageToStatus(name: string): string {
}
}
/**
* Computes a trend object from current vs previous count.
*/
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
const pct = Math.round(((current - previous) / previous) * 100)
return { pct: Math.abs(pct), up: pct >= 0 }
}
// Reusable SQL snippet that maps stage names to status strings
const stageStatusSql = `
CASE
WHEN ls.name = 'New' THEN 'open'
@@ -64,6 +87,8 @@ const stageStatusSql = `
ELSE 'open'
END`
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -76,6 +101,7 @@ export async function GET(request: NextRequest) {
const yearParam = searchParams.get("year")
let start: Date, end: Date, prevRange: { start: Date; end: Date }
if (yearParam) {
// Year mode: compare full year to previous full year
const y = parseInt(yearParam)
start = new Date(y, 0, 1)
end = new Date(y, 11, 31, 23, 59, 59)
@@ -86,9 +112,10 @@ export async function GET(request: NextRequest) {
prevRange = getPreviousPeriodRange(period, start)
}
// Non-admin users only see their own leads
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
// Status counts for current period (SQL aggregation)
// ── Status counts for current period (SQL aggregation) ──────────
const countSql = `
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
FROM leads l
@@ -129,7 +156,7 @@ export async function GET(request: NextRequest) {
const closedLeads = currentCounts.closed
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
// Monthly/weekly breakdown via date_trunc
// ── Monthly/weekly breakdown via date_trunc ─────────────────────
const isMonthly = period === "6months" || period === "12months"
const truncUnit = isMonthly ? "month" : "day"
const breakdownSql = `
@@ -148,7 +175,7 @@ export async function GET(request: NextRequest) {
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
const breakdownResult = await query(breakdownSql, breakdownParams)
// Build monthly breakdown from aggregated data
// Aggregate the grouped rows into a label-keyed map
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
for (const r of breakdownResult.rows) {
const d = new Date(r.period_start)
@@ -165,7 +192,7 @@ export async function GET(request: NextRequest) {
}
const monthlyBreakdown = Object.values(breakdownMap)
// Recent 10 leads
// ── Recent 10 leads ─────────────────────────────────────────────
const recentSql = `
SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
l.notes, l.assigned_to, l.score,
@@ -206,6 +233,7 @@ export async function GET(request: NextRequest) {
updatedAt: r.updated_at,
}))
// ── Trends (current vs previous period) ─────────────────────────
const trends = {
totalLeads: computeTrend(totalLeads, prevTotal),
openLeads: computeTrend(currentCounts.open, prevCounts.open),
+8
View File
@@ -1,7 +1,15 @@
// ── Emails: Sent Log ─────────────────────────────────────────────────────────
// GET /api/emails — List the 50 most recently sent emails
//
// Auth: admin/super_admin only
// Used for auditing outbound email communications from the system.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+9
View File
@@ -1,7 +1,16 @@
// ── Event Users ─────────────────────────────────────────────────────────────
// GET /api/event-users — List all active users available for event assignment
//
// Auth: authenticated
// Returns users with their roles, excluding the current user (cannot assign
// events to yourself as a participant). Used by the calendar UI for dropdowns.
import { NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
+10
View File
@@ -1,8 +1,16 @@
// ── Events: ICS Export ───────────────────────────────────────────────────────
// GET /api/events/[id]/ics — Export a calendar event as an .ics file
//
// Auth: authenticated; only the event creator can download the ICS
// Returns a text/calendar file for import into Outlook, Google Calendar, etc.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { buildIcs } from "@/lib/ics"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -10,6 +18,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const { id } = await params
// Fetch event details with creator and participant info; enforce ownership
const result = await query(
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
@@ -28,6 +37,7 @@ export async function GET(_request: NextRequest, { params }: { params: Promise<{
const r = result.rows[0]
// Build the ICS string via the shared utility
const icsContent = buildIcs({
uid: r.id,
title: r.title,
+18 -1
View File
@@ -1,8 +1,17 @@
// ── Events: Single Event ─────────────────────────────────────────────────────
// PATCH /api/events/[id] — Update an event (with permission checks & lead auto-close)
// DELETE /api/events/[id] — Delete an event (hard delete, creator only)
//
// Auth: authenticated; participants/developers can update limited fields;
// only the creator can delete or edit core event details.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { sendEventRescheduled } from "@/lib/email"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -11,6 +20,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
// Fetch current event with creator + participant details
const existing = await query(
`SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
@@ -37,6 +47,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// Non-creators cannot change core event details (title, type, time, participants)
if (!isCreator && (
(title !== undefined && title !== old.title) ||
(eventType !== undefined && eventType !== old.event_type) ||
@@ -49,6 +60,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
// COALESCE-based partial update: only overwrite provided fields
const result = await query(
`UPDATE scheduled_events SET
title = COALESCE($1, title),
@@ -88,7 +100,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0]
// Auto-close lead when developer marks event as completed
// Auto-close the associated lead when a developer marks the event as completed
if (status === "completed" && r.lead_id) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
if (stageResult.rows.length > 0) {
@@ -99,6 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// Send reschedule email if core fields changed
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
@@ -118,6 +131,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}).catch((err) => console.error("Reschedule email error:", err))
}
// Fetch creator info to return in the response
const creatorResult = await query(
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
ur.role_id, r.name AS role_name, r.display_name AS role_display
@@ -163,6 +177,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -180,6 +196,7 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
return NextResponse.json({ error: "Event not found" }, { status: 404 })
}
// Only the event creator can delete
if (existing.rows[0].user_id !== user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
+17 -1
View File
@@ -1,8 +1,17 @@
// ── Events: Collection ──────────────────────────────────────────────────────
// GET /api/events — List scheduled events (scoped to user's involvement)
// POST /api/events — Create a new event (with notifications & lead updates)
//
// Auth: authenticated
// GET supports filters: start, end (date range), status
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query, transaction } from "@/lib/db"
import { sendEventConfirmation } from "@/lib/email"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -13,6 +22,7 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end")
const status = searchParams.get("status")
// Build a large JOIN query that includes all associated user + lead info
let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
@@ -38,6 +48,7 @@ export async function GET(request: NextRequest) {
const params: unknown[] = []
let idx = 1
// Non-super_admins only see events where they are creator, participant, or developer
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id)
@@ -68,6 +79,7 @@ export async function GET(request: NextRequest) {
const result = await query(sql, params, user.id)
// Map rows to camelCase API shape with denormalized user/lead objects
const events = result.rows.map((r: any) => ({
id: r.id,
userId: r.user_id,
@@ -100,6 +112,8 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -115,6 +129,7 @@ export async function POST(request: NextRequest) {
if (!title) {
return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
// website_creation events don't require start time; all others do
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
}
@@ -129,6 +144,7 @@ export async function POST(request: NextRequest) {
[
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
title, description || null, eventType || "website_creation", startTime || null,
// For website_creation, endTime and durationMinutes are irrelevant
eventType === "website_creation" ? null : (endTime || null),
eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null, clientEmail || null, clientPhone || null,
@@ -136,7 +152,7 @@ export async function POST(request: NextRequest) {
)
const r = ins.rows[0]
// 2. Auto-update lead stage if website creation event
// 2. Auto-update lead stage to "Qualified" when a website creation event is scheduled
if (r.event_type === "website_creation" && leadId) {
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
+15
View File
@@ -1,9 +1,20 @@
// ── GIFs: Search/Trending ────────────────────────────────────────────────────
// GET /api/gifs?q=&limit=&pos= — Search or get trending GIFs via GIPHY proxy
//
// Auth: authenticated
// Supports search queries and trending fallback with pagination.
// Gracefully degrades: returns empty results array on error or missing API key.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
// ── Constants ────────────────────────────────────────────────────────────────
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -22,6 +33,7 @@ export async function GET(request: NextRequest) {
let data: any
if (q) {
// Search mode
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) {
@@ -31,6 +43,7 @@ export async function GET(request: NextRequest) {
}
data = await res.json()
} else {
// Trending mode with fallback to "funny" search if trending fails or is empty
const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
@@ -61,6 +74,8 @@ export async function GET(request: NextRequest) {
data = await fallbackRes.json()
}
}
// Normalize response to a minimal set of fields
const results = (data.data || []).map((item: any) => {
const dims = item.images?.original
return {
+11
View File
@@ -1,8 +1,17 @@
// ── Invite: Generate ─────────────────────────────────────────────────────────
// POST /api/invite/generate — Generate an invite link for a phone number
//
// Auth: super_admin only
// Rate-limited to 10 invites per hour. Creates a cryptographically random
// token and returns a full URL for sharing.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser, setSessionContext } from "@/lib/auth"
import { query } from "@/lib/db"
import crypto from "crypto"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -25,6 +34,7 @@ export async function POST(request: NextRequest) {
await setSessionContext(sessionUser.id, ipAddress)
// Rate limit: max 10 invites per rolling hour
const recentCount = await query(
`SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`,
)
@@ -32,6 +42,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 })
}
// Generate a 48-character hex token using cryptographically secure random bytes
const token = crypto.randomBytes(24).toString("hex")
await query(
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
+17
View File
@@ -1,8 +1,20 @@
// ── Leads: Notes ─────────────────────────────────────────────────────────────
// POST /api/leads/[id]/notes — Add a note to a lead
// GET /api/leads/[id]/notes — List notes for a lead (paginated)
//
// Auth: authenticated users with access to the lead (owner or admin)
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Checks whether the requesting user has access to the given lead.
* Access is granted if the user is the assigned owner or has an admin role.
*/
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
@@ -15,6 +27,8 @@ async function checkLeadAccess(leadId: string, userId: string): Promise<boolean>
return result.rows.length > 0
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -43,6 +57,8 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -56,6 +72,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Fetch notes with author info, ordered newest-first
const result = await query(
`SELECT cn.id, cn.created_at, cn.updated_at, cn.content,
u.id AS user_id, u.first_name, u.last_name, u.avatar_url
+27 -3
View File
@@ -1,8 +1,19 @@
// ── Leads: Single Lead ──────────────────────────────────────────────────────
// GET /api/leads/[id] — Get a single lead by ID
// PATCH /api/leads/[id] — Update lead fields (dynamic partial update)
//
// Auth: all authenticated users; scoped to own assignments or admin
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps the DB stage name to the client-facing status string.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -17,6 +28,8 @@ function stageToStatus(name: string): string {
}
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -25,6 +38,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Fetch lead with stage + user JOINs; enforce ownership scoping
const result = await query(
`SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
@@ -70,6 +84,12 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps client-facing status back to the default DB stage name.
* Used when creating or updating leads via PATCH.
*/
function statusToStageName(status: string): string {
switch (status) {
case "open": return "New"
@@ -81,6 +101,8 @@ function statusToStageName(status: string): string {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -89,7 +111,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Verify access
// Verify the user has access to this lead before applying updates
const accessCheck = await query(
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
AND ($2 = true OR assigned_to = $3)`,
@@ -104,6 +126,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const values: any[] = []
let idx = 1
// Build dynamic SET clause — only include provided fields
if (body.companyName !== undefined) { fields.push(`company_name = $${idx++}`); values.push(body.companyName) }
if (body.contactName !== undefined) { fields.push(`contact_name = $${idx++}`); values.push(body.contactName) }
if (body.email !== undefined) { fields.push(`email = $${idx++}`); values.push(body.email) }
@@ -111,15 +134,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
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.assignedUserId !== undefined) {
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Only admins can reassign leads
if (!isAdmin) {
// non-admin cannot reassign
return NextResponse.json({ error: "Only admins can reassign leads" }, { status: 403 })
}
fields.push(`assigned_to = $${idx++}`)
values.push(body.assignedUserId === "none" ? null : body.assignedUserId)
}
if (body.status !== undefined) {
// Resolve the client status → stage ID via the lookup table
const stageName = statusToStageName(body.status)
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
if (stageResult.rows.length > 0) {
@@ -140,6 +163,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
}
// Always bump the updated_at timestamp
fields.push(`updated_at = NOW()`)
values.push(id)
+34 -3
View File
@@ -1,8 +1,21 @@
// ── Leads: Collection ────────────────────────────────────────────────────────
// GET /api/leads — List leads with search, filter, pagination
// POST /api/leads — Create a new lead
// DELETE /api/leads?id= — Soft-delete a lead
//
// Auth: all authenticated users; non-admins scoped to own assignments
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Maps the DB stage name to the client-facing status string.
* Used for consistent status labels across the app.
*/
function stageToStatus(name: string): string {
switch (name) {
case "New": return "open"
@@ -17,6 +30,10 @@ function stageToStatus(name: string): string {
}
}
/**
* Returns { start, end } Date range for a given period key.
* Returns null for "all" (no date filtering).
*/
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
if (period === "all") return null
const end = new Date()
@@ -30,6 +47,8 @@ function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
return { start, end }
}
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -46,8 +65,10 @@ export async function GET(request: NextRequest) {
const params: any[] = []
let paramIdx = 1
// Base condition: exclude soft-deleted leads
const conditions: string[] = ["l.deleted_at IS NULL"]
// Apply date range filter if period is specified
if (period !== "all") {
const range = getPeriodDateRange(period)
if (range) {
@@ -57,19 +78,22 @@ export async function GET(request: NextRequest) {
}
}
// Apply free-text search across name, company, email, phone (case-insensitive)
if (search) {
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
params.push(`%${search}%`)
paramIdx++
}
// Non-admin users can only see their own assigned leads
if (!isAdmin) {
conditions.push(`l.assigned_to = $${paramIdx}`)
params.push(user.id)
paramIdx++
}
// Push status filter into SQL (avoid client-side filtering after pagination)
// Map client-facing status → DB stage names for server-side filtering
// This avoids pulling all rows and filtering client-side after pagination
const statusStageMap: Record<string, string[]> = {
open: ["New"],
contacted: ["Contacted"],
@@ -89,14 +113,14 @@ export async function GET(request: NextRequest) {
const whereClause = conditions.join(" AND ")
// Total count (same filters, no pagination)
// Total count query (same filters, no pagination)
const countResult = await query(
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
params.slice(0, paramIdx - 1),
)
const total = parseInt(countResult.rows[0]?.total || "0", 10)
// Data query with pagination
// Paginated data query with JOINs for stage name and assigned user
const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
ls.name AS stage_name,
@@ -110,6 +134,7 @@ export async function GET(request: NextRequest) {
const dataParams = [...params, limit, offset]
const result = await query(dataSql, dataParams)
// Shape rows into camelCase API response with denormalized assigned user
const leads = result.rows.map((r: any) => {
const s = stageToStatus(r.stage_name)
return {
@@ -140,6 +165,8 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -156,6 +183,7 @@ export async function POST(request: NextRequest) {
assignedUserId = null
}
// Look up the stage ID from the status name (default to "New" for open)
const stageResult = await query(
"SELECT id FROM lead_stages WHERE name = $1",
[body.status === "open" ? "New" : "Contacted"]
@@ -185,6 +213,8 @@ export async function POST(request: NextRequest) {
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -196,6 +226,7 @@ export async function DELETE(request: NextRequest) {
const id = searchParams.get("id")
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
// Soft-delete by setting deleted_at; admins can delete any lead, others only their own
const result = await query(
"UPDATE leads SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL AND ($2 = true OR assigned_to = $3) RETURNING id",
[id, isAdmin, user.id]
+10
View File
@@ -1,7 +1,15 @@
// ── Notifications: Single Notification ──────────────────────────────────────
// PATCH /api/notifications/[id] — Mark a single notification as read
// DELETE /api/notifications/[id] — Dismiss/delete a single notification
//
// Auth: authenticated; only the owning user can modify their notifications
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -25,6 +33,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
}
}
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -1,7 +1,16 @@
// ── Notification Preferences ─────────────────────────────────────────────────
// GET /api/notifications/preferences — Get current user's notification prefs
// PATCH /api/notifications/preferences — Upsert notification preferences
//
// Auth: authenticated
// Uses INSERT ... ON CONFLICT (user_id) DO UPDATE for idempotent upsert.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -14,6 +23,7 @@ export async function GET() {
[user.id],
)
// Return defaults if no row exists yet
if (result.rowCount === 0) {
return NextResponse.json({
leadAssigned: true,
@@ -38,6 +48,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -45,6 +57,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Upsert: create row if not exists, otherwise update
await query(
`INSERT INTO notification_preferences (user_id, lead_assigned, lead_status, note_added, daily_digest, weekly_report, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
+16
View File
@@ -1,7 +1,16 @@
// ── Notifications: Collection ────────────────────────────────────────────────
// GET /api/notifications — List the current user's recent notifications
// POST /api/notifications — Create a new notification (self or target user)
// PATCH /api/notifications — Mark all unread notifications as read
//
// Auth: authenticated
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -28,6 +37,7 @@ export async function GET() {
contextType: r.context_type,
}))
// Also return total unread count for badge display
const unreadResult = await query(
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
@@ -43,6 +53,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -50,6 +62,7 @@ export async function POST(request: NextRequest) {
const { type, title, description, link, userId } = await request.json()
const isAdmin = user.role === "admin" || user.role === "super_admin"
// Only admins can create notifications for other users; otherwise self-only
const targetUserId = (userId && isAdmin) ? userId : user.id
const result = await query(
@@ -75,11 +88,14 @@ export async function POST(request: NextRequest) {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Mark all unread notifications as read for the current user
await query(
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
[user.id],
+12
View File
@@ -1,7 +1,16 @@
// ── Settings: Company ───────────────────────────────────────────────────────
// GET /api/settings/company — Get company-wide settings
// PATCH /api/settings/company — Update company settings
//
// Auth: admin/super_admin only
// Falls back to sensible defaults if no row exists in company_settings.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -36,6 +45,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -46,6 +57,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Attempt update on existing row; if no row exists, insert one
const result = await query(
`UPDATE company_settings SET
company_name = $1, company_email = $2, company_phone = $3,
@@ -1,7 +1,15 @@
// ── Settings: Single Facebook Account ───────────────────────────────────────
// PATCH /api/settings/facebook/accounts/[id]
// — Update a Facebook scraping account (label, profile path, active, unflag)
//
// Auth: admin/super_admin only
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await getSessionUser()
@@ -16,6 +24,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const values: any[] = []
let idx = 1
// Build dynamic SET clause for partial updates
if (body.isActive !== undefined) {
fields.push(`is_active = $${idx++}`)
values.push(body.isActive)
@@ -25,12 +34,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
values.push(body.label.trim())
}
if (body.profilePath !== undefined) {
// Update both profile_path and the derived cookie_file path
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) {
// Clear flag status and reset failure counter
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
}
@@ -1,7 +1,15 @@
// ── Settings: Facebook Accounts ─────────────────────────────────────────────
// GET /api/settings/facebook/accounts — List all Facebook scraping accounts
// POST /api/settings/facebook/accounts — Add a new Facebook account
//
// Auth: admin/super_admin only
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -10,6 +18,7 @@ export async function GET() {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
// Fetch accounts with the latest scrape log result via LATERAL join
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,
@@ -37,6 +46,8 @@ export async function GET() {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -50,6 +61,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
}
// Derive the cookies file path from the profile path
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
const result = await query(
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
@@ -1,7 +1,15 @@
// ── Settings: Facebook Scrape Logs ──────────────────────────────────────────
// GET /api/settings/facebook/logs?accountId=&limit=&offset=
// — List Facebook scraping session logs, optionally filtered by account
//
// Auth: admin/super_admin only
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const user = await getSessionUser()
+12
View File
@@ -1,7 +1,16 @@
// ── Settings: User Preferences ───────────────────────────────────────────────
// GET /api/settings/preferences — Get the current user's preferences
// PATCH /api/settings/preferences — Upsert user preferences
//
// Auth: authenticated
// Preferences: timezone, dateFormat, itemsPerPage
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
@@ -32,6 +41,8 @@ export async function GET() {
}
}
// ── PATCH ────────────────────────────────────────────────────────────────────
export async function PATCH(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -39,6 +50,7 @@ export async function PATCH(request: NextRequest) {
const body = await request.json()
// Upsert: create if not exists, update if it does
await query(
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
VALUES ($1, $2, $3, $4, NOW())
@@ -1,12 +1,23 @@
// ── Settings: Website Theme ──────────────────────────────────────────────────
// GET /api/settings/website-theme — Get the current user's theme preference
// PUT /api/settings/website-theme — Update theme preference (stored in JSONB)
//
// Auth: authenticated
// Reads and writes the website_theme and color_theme keys inside the user's
// preferences JSONB column, preserving other preference data.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
try {
const user = await getSessionUser()
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
// Extract website_theme and color_theme from the JSONB preferences column
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
@@ -21,6 +32,8 @@ export async function GET() {
}
}
// ── PUT ──────────────────────────────────────────────────────────────────────
export async function PUT(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -35,6 +48,7 @@ export async function PUT(request: NextRequest) {
update.color_theme = body.colorTheme || "default"
}
// Merge into the existing JSONB preferences using || operator
if (Object.keys(update).length > 0) {
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
@@ -42,6 +56,7 @@ export async function PUT(request: NextRequest) {
)
}
// Return the updated values
const result = await query(
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
[user.id],
+11 -1
View File
@@ -1,10 +1,20 @@
// ── System: Monitor ──────────────────────────────────────────────────────────
// GET /api/system/monitor — Return basic system health metrics
//
// Auth: authenticated
// Returns: RSS memory (MB), CPU usage (% of one core), CPU core count
// CPU is calculated as process CPU time delta over wall clock time.
import { NextResponse } from "next/server"
import os from "os"
import { getSessionUser } from "@/lib/auth"
// Module-level state for CPU delta calculation
let prevCpu = process.cpuUsage()
let prevTime = Date.now()
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET() {
const sessionUser = await getSessionUser()
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
@@ -17,7 +27,7 @@ export async function GET() {
const sys = currentCpu.system - prevCpu.system
const totalUs = user + sys
// CPU time (ms) / wall time (ms) * 100 = % of one core
// CPU time (microseconds) / wall time (milliseconds * 1000) * 100 = % of one core
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
prevCpu = currentCpu
+11
View File
@@ -1,9 +1,19 @@
// ── Upload: Voice Message ────────────────────────────────────────────────────
// POST /api/upload/voice — Upload a voice note audio file
//
// Auth: authenticated
// Accepts multipart/form-data with an "audio" file field and "duration" field.
// Saves the file to public/uploads/voice/ with a UUID filename.
// Returns the public URL path and duration for use in chat messages.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { writeFile } from "node:fs/promises"
import { join } from "node:path"
import crypto from "node:crypto"
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -15,6 +25,7 @@ export async function POST(request: NextRequest) {
const duration = parseFloat(formData.get("duration")?.toString() || "0")
// Always use .webm extension (normalize from whatever the client sends)
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
const filename = `${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
+9
View File
@@ -1,7 +1,14 @@
// ── Users: Single User ───────────────────────────────────────────────────────
// DELETE /api/users/[id] — Soft-delete a user account
//
// Auth: super_admin only; cannot delete yourself
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { getSessionUser } from "@/lib/auth"
// ── DELETE ───────────────────────────────────────────────────────────────────
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const sessionUser = await getSessionUser()
@@ -14,10 +21,12 @@ export async function DELETE(request: NextRequest, { params }: { params: Promise
const { id } = await params
// Prevent self-deletion to avoid admin lockout
if (id === sessionUser.id) {
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
}
// Soft-delete: set deleted_at rather than removing the row
await query(
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
[id]
+13 -1
View File
@@ -1,10 +1,20 @@
// ── Users: Avatar Upload ────────────────────────────────────────────────────
// POST /api/users/avatar — Update the current user's avatar (base64 data URL)
//
// Auth: authenticated
// Accepts PNG, JPEG, GIF data URIs up to 2MB decoded size.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── Constants ────────────────────────────────────────────────────────────────
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
@@ -15,18 +25,20 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
}
// Validate image format by checking the data URL prefix
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
// Approximate decoded size: base64 encodes ~4 bytes as 3 bytes of data
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 })
}
// Store the full data URL in the avatar_url column
await query(
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
[avatar, user.id],
+8
View File
@@ -1,7 +1,15 @@
// ── Users: Phone Lookup ──────────────────────────────────────────────────────
// GET /api/users/lookup?phone= — Look up a user by phone number
//
// Auth: admin/super_admin only
// Returns found: true + user data or found: false if no match.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
const sessionUser = await getSessionUser()
if (!sessionUser) {
+15
View File
@@ -1,8 +1,16 @@
// ── Users: Collection ────────────────────────────────────────────────────────
// GET /api/users — List users (paginated); admin/super_admin only
// POST /api/users — Create a new user; super_admin only
//
// Auth: GET requires admin/super_admin; POST requires super_admin
import { NextRequest, NextResponse } from "next/server"
import { query } from "@/lib/db"
import { hashPassword, getSessionUser, encryptPassword, setSessionContext } from "@/lib/auth"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -15,6 +23,7 @@ export async function GET(request: NextRequest) {
const limit = parseInt(searchParams.get("limit") || "50", 10)
const offset = parseInt(searchParams.get("offset") || "0", 10)
// Fetch users with their role name, excluding soft-deleted records
const result = await query(
`SELECT u.id, u.username, u.email, u.first_name, u.last_name,
u.is_active AS active, u.created_at, u.avatar_url,
@@ -43,6 +52,8 @@ export async function GET(request: NextRequest) {
}
}
// ── POST ─────────────────────────────────────────────────────────────────────
export async function POST(request: NextRequest) {
try {
const sessionUser = await getSessionUser()
@@ -63,6 +74,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
}
// Derive first/last name from full name string
const nameParts = name.trim().split(/\s+/)
const firstName = nameParts[0]
const lastName = nameParts.slice(1).join(" ") || firstName
@@ -72,6 +84,7 @@ export async function POST(request: NextRequest) {
await setSessionContext(sessionUser.id)
// Insert user record with both hash (for login) and encrypted (for recovery)
const result = await query(
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
@@ -79,6 +92,7 @@ export async function POST(request: NextRequest) {
[username.toLowerCase(), email.toLowerCase(), passwordHash, passwordEncrypted, firstName, lastName, active ?? true, sessionUser.id]
)
// Assign the requested role via the user_roles junction table
const roleId = (
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
).rows[0]?.id
@@ -94,6 +108,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
} catch (error: any) {
console.error("Error creating user:", error)
// Handle unique constraint violations for username or email
if (error?.constraint === "uq_users_username" || error?.constraint === "uq_users_email") {
return NextResponse.json({ error: "A user with this email or username already exists" }, { status: 409 })
}
+9
View File
@@ -1,8 +1,16 @@
// ── Users: Search ────────────────────────────────────────────────────────────
// GET /api/users/search?q= — Typeahead search users by name or email
//
// Auth: admin/super_admin only
// Returns up to 10 matching users, excluding the requester themselves.
import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth"
import { query } from "@/lib/db"
import { avatarSvgUrl } from "@/lib/avatar"
// ── GET ──────────────────────────────────────────────────────────────────────
export async function GET(request: NextRequest) {
try {
const currentUser = await getSessionUser()
@@ -17,6 +25,7 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ users: [] })
}
// Case-insensitive search on full name and email; exclude current user
const result = await query(
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
FROM users
+41
View File
@@ -1,9 +1,33 @@
// ───────────────────────────────────────────────
// Call Room Page (/call/[roomId])
// ───────────────────────────────────────────────
// Route: /call/:roomId
// Purpose: Anonymous WebRTC voice-call room.
// Handles pre-join (name entry, mic permission),
// connection states (calling, waiting, connecting,
// connected), and post-call (ended) states.
// Layout: Centered card with state-dependent UI
// — join form, call controls, or end screen.
// Uses: useWebRTCCall hook with anonymous mode.
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, use, useCallback } from "react"
import { Phone, PhoneOff, Mic, MicOff, Volume2, VolumeX, Users, Loader2 } from "lucide-react"
import { useWebRTCCall } from "@/hooks/useWebRTCCall"
/**
* CallRoomPage
*
* Manages the full lifecycle of an anonymous voice
* call in a room. Pre-join: requests mic, shows
* name input. Joined: renders call state UI.
* Auto-detects connection timeouts (10 s).
*
* @param params - Route params containing roomId.
* @returns Call room UI for each state.
*/
export default function CallRoomPage({ params }: { params: Promise<{ roomId: string }> }) {
const { roomId } = use(params)
@@ -27,6 +51,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
const [micError, setMicError] = useState<string | null>(null)
const [connectionTimeout, setConnectionTimeout] = useState(false)
// ── 10-second connection timeout detection ──
useEffect(() => {
if (isReady) return
const timer = setTimeout(() => {
@@ -35,6 +60,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
return () => clearTimeout(timer)
}, [isReady])
// ── Join handler: requests mic, then joins room ──
const handleJoin = useCallback(async () => {
if (!displayName.trim()) return
setMicError(null)
@@ -51,15 +77,18 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
endCall()
}, [endCall])
// ── Pre-join UI (not yet in the room) ──
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? (
// Socket connection failed after 10 s
<>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
</>
) : !isReady ? (
// Still connecting to signaling server
<>
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
@@ -67,6 +96,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
</>
) : (
// Name entry form
<>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
{micError && (
@@ -95,9 +125,11 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
)
}
// ── In-call UI (joined) ──
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{/* ── Calling state ── */}
{callState === "calling" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
@@ -110,6 +142,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Waiting for other participant ── */}
{callState === "waiting" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
@@ -133,6 +166,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Participant joined — transitioning to connect ── */}
{callState === "participant_joined" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
@@ -156,6 +190,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Establishing WebRTC connection ── */}
{callState === "connecting" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
@@ -168,6 +203,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Active call with controls ── */}
{callState === "connected" && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
@@ -186,18 +222,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{formatDuration(callDuration)}
</div>
<div className="flex justify-center gap-4">
{/* Speaker toggle */}
<button
onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button>
{/* Mute toggle */}
<button
onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
{/* End call */}
<button
onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
@@ -208,6 +247,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Call ended / idle ── */}
{(callState === "ended" || callState === "idle") && (
<div>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
@@ -217,6 +257,7 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
</div>
)}
{/* ── Error banner ── */}
{error && (
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
)}
+41
View File
@@ -1,3 +1,18 @@
// ───────────────────────────────────────────────
// Join (Invite) Page (/join/[token])
// ───────────────────────────────────────────────
// Route: /join/:token
// Purpose: Server component that validates an
// invite token. If the token is a UUID (room
// ID), it redirects directly to /call/:token.
// Otherwise it queries the DB for an SMS-style
// invite and shows a "You're Invited!" landing
// page with the caller's phone number.
// Layout: Centered card with invite details and
// a "Create Account" CTA.
// Error states: invalid token, expired token.
// ───────────────────────────────────────────────
import { query } from "@/lib/db"
import { redirect } from "next/navigation"
@@ -5,6 +20,16 @@ interface Props {
params: Promise<{ token: string }>
}
/**
* ErrorPage
*
* Shared inline component for rendering error
* states (invalid / expired) with the same
* card styling.
*
* @param message - The error message to display.
* @returns A centered error card.
*/
function ErrorPage({ message }: { message: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
@@ -17,15 +42,28 @@ function ErrorPage({ message }: { message: string }) {
)
}
/**
* JoinPage
*
* Validates an invite token. UUID tokens redirect
* directly into a call room. DB-backed tokens
* (from SMS invites) show a landing page with
* the inviter's contact info and a register CTA.
*
* @param params - Route params containing the token.
* @returns An error page, redirect, or invite page.
*/
export default async function JoinPage({ params }: Props) {
const { token } = await params
// ── UUID tokens → direct call room redirect ──
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
if (UUID_REGEX.test(token)) {
redirect(`/call/${token}`)
}
// ── Look up SMS invite token in DB ──
const result = await query(
`SELECT phone, created_at, expires_at FROM invites WHERE token = $1 LIMIT 1`,
[token],
@@ -37,6 +75,7 @@ export default async function JoinPage({ params }: Props) {
const invite = result.rows[0]
// ── Check expiration ──
if (new Date(invite.expires_at) <= new Date()) {
return <ErrorPage message="This call has expired." />
}
@@ -50,6 +89,7 @@ export default async function JoinPage({ params }: Props) {
<p className="text-[#8A9078] text-sm mb-6">
Someone wants to connect with you on our platform.
</p>
{/* ── Inviter's contact number ── */}
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
@@ -57,6 +97,7 @@ export default async function JoinPage({ params }: Props) {
<p className="text-xs text-[#8A9078] mb-6">
Create an account to start making free voice calls to this person and others on the platform.
</p>
{/* ── Registration CTA ── */}
<a
href="/register"
className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
+69
View File
@@ -1,11 +1,28 @@
// ───────────────────────────────────────────────
// Login Page (/login)
// ───────────────────────────────────────────────
// Route: /login
// Purpose: Client-side login page with animated
// background (canvas waves + stars), a hero
// testimonial carousel, live stat counters, and
// a credential form that hits /api/auth/login.
// Layout: Two-panel split — left = branding +
// stats, right = login form.
//
// NOTE: Wrapped in <Suspense> because LoginForm
// calls useSearchParams().
// ───────────────────────────────────────────────
"use client"
import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react"
/** LocalStorage key used to persist the user's chosen website theme. */
const WEBSITE_THEME_KEY = "crm-website-theme"
/** Configuration for the animated sine-wave canvas (amplitude, frequency, speed, vertical offset, fill). */
const waves = [
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
@@ -14,6 +31,16 @@ const waves = [
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
]
/**
* LoginPage
*
* Wraps LoginForm in Suspense because it uses
* useSearchParams (requires a client boundary
* with fallback UI).
*
* @returns A spinner fallback while the form loads,
* then the LoginForm component.
*/
export default function LoginPage() {
return (
<Suspense fallback={
@@ -26,6 +53,17 @@ export default function LoginPage() {
)
}
/**
* LoginForm
*
* Core login UI. Manages form state, animated
* canvas background (waves + stars), rotating
* testimonial carousel, and animated stat
* counters. On submit, POSTs to /api/auth/login
* and redirects on success.
*
* @returns The full login-page JSX layout.
*/
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
@@ -39,6 +77,7 @@ function LoginForm() {
const counterStarted = useRef(false)
const [testimonialIndex, setTestimonialIndex] = useState(0)
/** Rotating testimonials shown in the left panel. */
const testimonials = [
{
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
@@ -50,15 +89,19 @@ function LoginForm() {
},
]
// ── Rotate testimonials every 5 seconds ──
useEffect(() => {
const timer = setInterval(() => {
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
}, 5000)
return () => clearInterval(timer)
}, [testimonials.length])
/** Ref for the sine-wave canvas (left panel). */
const canvasRef = useRef<HTMLCanvasElement>(null)
/** Ref for the star-field canvas (right panel). */
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
// ── Animated sine-wave canvas (left panel background) ──
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
@@ -68,6 +111,7 @@ function LoginForm() {
let animId: number
let time = 0
/** Handle HiDPI (Retina) display scaling. */
const resize = () => {
const parent = canvas.parentElement!
const rect = parent.getBoundingClientRect()
@@ -82,6 +126,7 @@ function LoginForm() {
resize()
window.addEventListener("resize", resize)
/** Render each wave layer using a sine function. */
const draw = () => {
const w = canvas.width / (window.devicePixelRatio || 1)
const h = 200
@@ -112,10 +157,12 @@ function LoginForm() {
}
}, [])
// ── Twinkling star-field canvas (right panel background) ──
useEffect(() => {
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
if (canvases.length === 0) return
/** Generate 312 stars with random positions, sizes, opacities, and drift velocities. */
const stars = Array.from({ length: 312 }, () => ({
x: Math.random(),
y: Math.random(),
@@ -130,6 +177,7 @@ function LoginForm() {
let animId: number
let time = 0
/** Handle HiDPI resize for all star canvases. */
const resize = () => {
for (const c of canvases) {
const dpr = window.devicePixelRatio || 1
@@ -149,6 +197,7 @@ function LoginForm() {
})
window.addEventListener("resize", resize)
/** Render each star with a sinusoidal opacity twinkle and slow parallax drift. */
const draw = () => {
time += 1
for (const c of canvases) {
@@ -169,6 +218,7 @@ function LoginForm() {
ctx.fillStyle = `rgba(255,255,255,${opacity})`
ctx.fill()
// Brighter stars get a subtle glow halo
if (star.r > 0.5) {
ctx.beginPath()
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
@@ -190,12 +240,14 @@ function LoginForm() {
}
}, [])
// ── Animated stat counters (left panel) ──
useEffect(() => {
if (counterStarted.current) return
counterStarted.current = true
const targets = { leads: 1247, conversion: 40, users: 83 }
const steps = 150
// Delayed start so the user sees the page first
const delayTimer = setTimeout(() => {
let step = 0
const interval = setInterval(() => {
@@ -205,6 +257,7 @@ function LoginForm() {
setCounts(targets)
return
}
// Increment each counter proportionally toward its target
setCounts({
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
@@ -216,6 +269,7 @@ function LoginForm() {
return () => clearTimeout(delayTimer)
}, [])
// ── Form submission handler ──
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
@@ -229,9 +283,11 @@ function LoginForm() {
})
if (res.ok) {
// Clear any persisted theme so dashboard picks up the user's saved preference
localStorage.removeItem(WEBSITE_THEME_KEY)
const root = document.documentElement
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
// Respect ?redirect= query param (set by middleware for unauthenticated access)
const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else {
@@ -246,7 +302,9 @@ function LoginForm() {
}
return (
// ── Full-page two-panel layout ──
<div className="flex min-h-screen bg-[#0a0a0f]">
{/* ── LEFT PANEL: Branding, stats, testimonials ── */}
<div className="left-panel flex flex-1 flex-col p-12">
<div className="relative z-10 flex-1 flex items-center justify-center">
<div className="text-center">
@@ -261,6 +319,7 @@ function LoginForm() {
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
Manage leads, track conversions, and grow your web development business with our CRM.
</p>
{/* Animated stat counters */}
<div className="stats-row">
<div className="stat">
<div className="stat-number">{counts.leads.toLocaleString()}</div>
@@ -278,6 +337,7 @@ function LoginForm() {
</div>
</div>
{/* ── Testimonial carousel ── */}
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
<svg width="0" height="0" style={{ position: "absolute" }}>
<defs>
@@ -286,6 +346,7 @@ function LoginForm() {
</clipPath>
</defs>
</svg>
{/* Key changes on testimonialIndex to trigger transition */}
<div className="transition-opacity duration-500" key={testimonialIndex}>
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
&ldquo;{testimonials[testimonialIndex].text}&rdquo;
@@ -294,6 +355,7 @@ function LoginForm() {
{testimonials[testimonialIndex].author}
</p>
</div>
{/* Dot indicators for manual testimonial selection */}
<div className="flex items-center justify-center gap-1.5 mt-3">
{testimonials.map((_, i) => (
<button
@@ -311,6 +373,7 @@ function LoginForm() {
<div className="panel-divider" />
{/* ── RIGHT PANEL: Login form ── */}
<div className="right-panel flex items-center justify-center p-10">
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
<div className="accent-line" />
@@ -323,6 +386,7 @@ function LoginForm() {
Sign in to your account to continue
</p>
{/* Error alert banner */}
{error && (
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
{error}
@@ -330,6 +394,7 @@ function LoginForm() {
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* Email field */}
<div>
<label htmlFor="email" className="login-label">
Email
@@ -348,6 +413,7 @@ function LoginForm() {
</div>
</div>
{/* Password field with show/hide toggle and forgot link */}
<div>
<div className="flex items-center justify-between mb-[6px]">
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
@@ -384,6 +450,7 @@ function LoginForm() {
</div>
</div>
{/* Remember me checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
@@ -396,12 +463,14 @@ function LoginForm() {
</span>
</label>
{/* Submit button with loading state */}
<button type="submit" className="auth-btn" disabled={loading}>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Signing in..." : "Sign in"}
</button>
</form>
{/* Legal footer links */}
<p className="text-center text-[11px] mt-4 footer-text">
By signing in, you agree to our{" "}
<button type="button" className="text-[#1BB0CE] hover:underline">
+20
View File
@@ -1,5 +1,25 @@
// ───────────────────────────────────────────────
// Root Page (/)
// ───────────────────────────────────────────────
// Route: /
// Purpose: Top-level entry point for the app.
// Immediately redirects authenticated users to
// /dashboard. Unauthenticated users are caught
// by middleware and redirected to /login.
// Layout: No layout — pure redirect.
// ───────────────────────────────────────────────
import { redirect } from "next/navigation"
/**
* RootPage
*
* The default route handler for `/`. Immediately
* redirects to the dashboard. Auth guarding is
* handled by src/middleware.ts.
*
* @returns Never always calls redirect().
*/
export default function RootPage() {
redirect("/dashboard")
}
+41
View File
@@ -1,9 +1,17 @@
// ── AI Assistant: Main Chat Component ──
// Full conversational AI chat interface with message history, GIF support,
// server-health polling, localStorage persistence, and an animated boot sequence.
"use client"
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Bot, Terminal } from "lucide-react"
import { GifPicker } from "./gif-picker"
/**
* Converts URLs in text into clickable anchor elements.
* Splits text by URL regex and renders each part as either a link or plain text fragment.
*/
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
@@ -15,6 +23,10 @@ function linkifyText(text: string) {
})
}
/**
* Renders AI response text with support for bullet lists ( or -) and paragraphs.
* Each line is transformed: bullets get a colored dot prefix, empty lines become spacing.
*/
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
@@ -33,6 +45,7 @@ function formatContent(text: string) {
})
}
/** A single chat message, either from the user or the AI assistant */
interface ChatMessage {
role: "user" | "assistant"
content: string
@@ -45,16 +58,24 @@ interface ChatMessage {
}
interface AIChatProps {
/** Called when the user sends a message */
onMessageSent?: (msg: string) => void
}
/** Exposed imperative methods for parent components */
export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
/**
* AIChat the main conversational AI assistant interface.
* Features: boot animation, message history with localStorage, GIF picker,
* markdown-like formatting, and automatic scroll-to-bottom.
*/
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
// ── State & refs ──
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
@@ -67,6 +88,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
const hasUserMessage = messages.some(m => m.role === "user")
const loadedFromStorage = useRef(false)
// ── Expose imperative methods to parent via ref ──
useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
@@ -85,11 +107,13 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
},
}), [])
// ── Handle GIF selection from the GIF picker ──
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
setShowGifPicker(false)
}, [])
// ── Close GIF picker on outside click ──
useEffect(() => {
if (showGifPicker) {
const handler = (e: MouseEvent) => {
@@ -102,6 +126,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [showGifPicker])
// ── Load messages from localStorage, filtering out messages older than 1 week ──
const _WEEK_MS = 604800000
useEffect(() => {
@@ -123,6 +148,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── Persist messages to localStorage whenever they change ──
useEffect(() => {
if (messages.length > 0) {
const now = Date.now()
@@ -130,6 +156,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [messages])
// ── Poll the AI server until it is no longer returning 503 (booting) ──
const checkServer = useCallback(async () => {
try {
const res = await fetch("/api/ai/chat", {
@@ -147,6 +174,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [])
// ── On mount: poll server, load jobs to build welcome message ──
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
@@ -180,10 +208,12 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
})
}, [checkServer])
// ── Auto-scroll to bottom when new messages arrive ──
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
// ── Send a message to the AI API ──
const sendMessage = useCallback(async (text?: string) => {
const msg = (text || input).trim()
if (!msg || loading) return
@@ -220,6 +250,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [input, loading, onMessageSent])
// ── Enter key submits; Shift+Enter inserts newline ──
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
@@ -227,12 +258,14 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
}
}, [sendMessage])
// ── Auto-resize textarea on input ──
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
// ── Boot screen ──
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
@@ -259,6 +292,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Welcome screen (ready, no user messages yet) ──
if (bootState === "ready" && !hasUserMessage) {
return (
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
@@ -273,6 +307,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
@@ -307,13 +342,16 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)
}
// ── Chat view (messages present) ──
return (
<div className="flex-1 flex flex-col min-h-0">
{/* ── Message list ── */}
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, i) => (
<div key={i}>
{msg.role === "assistant" ? (
/* ── AI assistant bubble (left-aligned) ── */
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
@@ -326,6 +364,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
</div>
</div>
) : (
/* ── User bubble (right-aligned) ── */
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
@@ -345,6 +384,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
)}
</div>
))}
{/* ── Typing indicator (bouncing dots) ── */}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
@@ -360,6 +400,7 @@ export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent },
<div ref={messagesEndRef} />
</div>
</div>
{/* ── Input area (sticky bottom) ── */}
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
{error && (
+23
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: GIF Picker Component ──
// Inline GIF search and selection UI backed by GIPHY.
// Supports trending, search with debounce, infinite scroll, and client-side caching.
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { Search, Loader2, ImageIcon } from "lucide-react"
/** A single GIF result from the GIPHY API */
interface GifResult {
id: string
title: string
@@ -14,12 +19,19 @@ interface GifResult {
}
interface GifPickerProps {
/** Called when a GIF is selected */
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
/** Called to close the picker */
onClose: () => void
}
/** In-memory cache keyed by search query or "__trending__" */
const SEARCH_CACHE = new Map<string, GifResult[]>()
/**
* GifPicker inline GIF search/selection popover.
* Fetches trending GIFs on open, supports debounced search and infinite scroll via IntersectionObserver.
*/
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const [gifs, setGifs] = useState<GifResult[]>([])
const [loading, setLoading] = useState(true)
@@ -32,6 +44,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const inputRef = useRef<HTMLInputElement>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
// ── Fetch GIFs from the API, with optional cache hit ──
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
if (off === 0) {
const cacheKey = q || "__trending__"
@@ -45,6 +58,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}
// ── Perform the API request ──
try {
if (!append) setLoading(true)
else setLoadingMore(true)
@@ -67,6 +81,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const data = await res.json()
const newGifs: GifResult[] = data.gifs || []
// Append or replace results, caching on first page
if (append) {
setGifs((prev) => [...prev, ...newGifs])
} else {
@@ -89,14 +104,17 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [])
// ── Load trending GIFs on mount ──
useEffect(() => {
fetchGifs("", 0, false)
}, [fetchGifs])
// ── Auto-focus the search input ──
useEffect(() => {
inputRef.current?.focus()
}, [])
// ── Debounced search: 400ms after the user stops typing ──
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
if (!search.trim()) {
@@ -111,6 +129,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}
}, [search, fetchGifs])
// ── Infinite scroll via IntersectionObserver on the sentinel ──
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
@@ -129,7 +148,9 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
return (
// ── Popover container ──
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
{/* ── Search bar ── */}
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
@@ -144,6 +165,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
</div>
</div>
{/* ── GIF grid with loading / error / empty states ── */}
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
{loading ? (
<div className="flex items-center justify-center py-16">
@@ -184,6 +206,7 @@ export function GifPicker({ onSelect, onClose }: GifPickerProps) {
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
{/* Sentinel element for infinite scroll detection */}
{hasMore && <div ref={sentinelRef} className="h-4" />}
</>
)}
+18
View File
@@ -1,8 +1,13 @@
// ── AI Assistant: Job Selector Component ──
// Dropdown that fetches and displays job categories from the API.
// Allows the user to pick a job, then triggers a Facebook search.
"use client"
import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
/** A job category returned from the API */
interface Job {
job_title: string
keywords: string[]
@@ -11,17 +16,25 @@ interface Job {
}
interface JobSelectorProps {
/** Called when a job is selected or cleared (null) */
onSelect: (job: Job | null) => void
/** Called when the user clicks the "Search Facebook" button */
onSearch?: (job: Job) => void
/** Whether a search is currently in progress */
searching?: boolean
}
/**
* JobSelector dropdown to pick a job category and initiate a Facebook search.
* Fetches available jobs on mount and provides a "Search Facebook" CTA once a job is selected.
*/
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [selected, setSelected] = useState<Job | null>(null)
// ── Fetch available job categories on mount ──
useEffect(() => {
fetch("/api/ai/jobs")
.then((r) => r.json())
@@ -30,6 +43,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
.finally(() => setLoading(false))
}, [])
// ── Select a job, close dropdown, notify parent ──
const handleSelect = (job: Job) => {
setSelected(job)
setOpen(false)
@@ -38,6 +52,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
return (
<div className="relative">
{/* ── Dropdown trigger button ── */}
<button
type="button"
onClick={() => setOpen(!open)}
@@ -50,8 +65,10 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button>
{/* ── Dropdown menu — visible when open ── */}
{open && (
<>
{/* Backdrop to close on click outside */}
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
@@ -71,6 +88,7 @@ export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps)
</div>
</>
)}
{/* ── "Search Facebook" CTA — shown only when a job is selected ── */}
{selected && onSearch && (
<button
type="button"
+13
View File
@@ -1,3 +1,8 @@
// ── Chats: Media Picker Component ──
// Tabbed popover for selecting emojis, GIFs (via GIPHY), and stickers.
// Emojis use emoji-mart, GIFs have search + trending + infinite scroll + caching,
// stickers support multiple packs with recent items stored in localStorage.
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
@@ -19,21 +24,29 @@ interface MediaPickerProps {
theme?: string
}
/** localStorage keys for recent media tracking */
const RECENT_GIFS_KEY = "recent-gifs"
const RECENT_STICKERS_KEY = "recent-stickers"
const MAX_RECENT = 12
/** Retrieve recent items from localStorage */
function getRecent(key: string): string[] {
if (typeof window === "undefined") return []
try { return JSON.parse(localStorage.getItem(key) || "[]") } catch { return [] }
}
/** Add an item to the recent list, capped at MAX_RECENT, deduplicated */
function addRecent(key: string, id: string) {
if (typeof window === "undefined") return
const items = [id, ...getRecent(key).filter((x) => x !== id)].slice(0, MAX_RECENT)
localStorage.setItem(key, JSON.stringify(items))
}
/**
* MediaPicker a popover with three tabs:
* 1) Emoji (emoji-mart picker), 2) GIFs (search + trending), 3) Stickers (multi-pack).
* GIFs use debounced search, caching, pagination, and error/offline handling.
*/
export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, theme }: MediaPickerProps) {
const [tab, setTab] = useState<Tab>("emoji")
const [gifQuery, setGifQuery] = useState("")
+10
View File
@@ -1,3 +1,7 @@
// ── Chats: Voice Call Modal Component ──
// WebRTC-based voice call UI. Allows searching contacts or dialing a number,
// creates a call room, and shares the join link via WhatsApp or clipboard.
"use client"
import { useState, useEffect, useCallback } from "react"
@@ -17,6 +21,7 @@ interface VoiceCallModalProps {
onClose: () => void
}
/** Converts a phone number to international format suitable for WhatsApp links */
function formatPhoneForWhatsApp(phone: string): string {
const digits = phone.replace(/[^0-9]/g, "")
if (!digits) return ""
@@ -25,6 +30,11 @@ function formatPhoneForWhatsApp(phone: string): string {
return digits
}
/**
* VoiceCallModal modal for initiating a WebRTC voice call.
* Steps: 1) Select contact or dial number, 2) Create room & get link,
* 3) Share link via WhatsApp or copy to clipboard, 4) Open call page.
*/
export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
const [contacts, setContacts] = useState<Contact[]>([])
const [contactsLoading, setContactsLoading] = useState(false)
@@ -1,5 +1,12 @@
// ── Dashboard: Crossed Lightsabers SVG ──
// Animated SVG of two crossed lightsabers (red and blue) with glow effects and pulsing hilts.
"use client"
/**
* CrossedLightsabers decorative SVG with animated red and blue lightsabers crossed.
* Includes a purple glow circle at the intersection point and detailed hilt/button details.
*/
export function CrossedLightsabers() {
return (
<svg
@@ -1,3 +1,8 @@
// ── Dashboard: Lead Status Donut Chart ──
// Interactive SVG donut chart showing lead distribution by status.
// Hover highlights a segment and shows its value/percentage in the center.
// Includes a legend with colored dots and percentages.
"use client"
import { useState } from "react"
@@ -14,11 +19,13 @@ interface LeadStatusChartProps {
data: StatusData[]
}
/** Convert polar coordinates to cartesian */
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }
}
/** Generate an SVG arc path for a donut segment with inner and outer radii */
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
const gap = 3
const s = start + gap / 2
@@ -31,6 +38,10 @@ function arcPath(cx: number, cy: number, oR: number, iR: number, start: number,
return `M${o1.x} ${o1.y} A${oR} ${oR} 0 ${lg} 1 ${o2.x} ${o2.y} L${i1.x} ${i1.y} A${iR} ${iR} 0 ${lg} 0 ${i2.x} ${i2.y}Z`
}
/**
* LeadStatusChart donut chart with hover interactions, center text display,
* and a legend grid. Shows "No data" when total is 0.
*/
export function LeadStatusChart({ data }: LeadStatusChartProps) {
const [hov, setHov] = useState<number | null>(null)
@@ -1,3 +1,7 @@
// ── Dashboard: Leads Per Month Bar Chart ──
// Interactive SVG bar chart comparing new leads vs. closed leads per month.
// Supports year navigation, hover tooltip with close rate, and gradient-filled bars.
"use client"
import { useState, useMemo, useEffect } from "react"
@@ -6,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ChevronLeft, ChevronRight } from "lucide-react"
/** A single month's data point */
interface IntervalData {
label: string
leads: number
@@ -16,9 +21,15 @@ interface LeadsPerMonthChartProps {
data: IntervalData[]
}
/** Color constants for new leads and closed bars */
const NEW_LEADS = "#C84B4B"
const CLOSED = "#5A8FC4"
/**
* LeadsPerMonthChart bar chart rendered with SVG.
* Features: year navigation, hover highlight/tooltip, legend, gradient fills,
* grid lines with tick labels, and summary stats (total new, total closed, close rate).
*/
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear())
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
@@ -1,3 +1,7 @@
// ── Dashboard: Recent Leads Table ──
// Compact table showing the most recent leads with company (linked), contact, status badge,
// assigned user avatar, and date. "View all" link navigates to /leads.
"use client"
import Link from "next/link"
@@ -8,6 +12,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Lead } from "@/types"
import { ArrowRight } from "lucide-react"
/** Color-coded status badge classes */
const statusStyles: Record<string, string> = {
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
@@ -20,6 +25,10 @@ interface RecentLeadsTableProps {
leads: Lead[]
}
/**
* RecentLeadsTable dashboard widget showing the latest leads in a responsive table.
* Supports hover row highlighting and staggered-row animations.
*/
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
return (
<motion.div
+7
View File
@@ -1,3 +1,6 @@
// ── Dashboard: Star Field Background ──
// Decorative fixed-position animated starfield with randomized positions, sizes, and twinkle timings.
"use client"
import { useMemo } from "react"
@@ -10,6 +13,10 @@ interface Star {
duration: number
}
/**
* StarField renders 160 fixed-position stars with randomized animation delays and durations.
* The stars twinkle using the CSS `star-twinkle` animation class.
*/
export function StarField() {
const stars = useMemo<Star[]>(() => {
const result: Star[] = []
@@ -1,7 +1,15 @@
// ── Dashboard: Stat Card Skeleton Loading ──
// Full-width loading placeholder shown while dashboard stats are being fetched.
// Displays "THWIP!" with a loading bar for the Spidey theme, or a generic "Loading your data..." message.
"use client"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
/**
* StatCardSkeleton full-width loading state for dashboard stat cards.
* Shows theme-specific messaging ("THWIP!" for Spidey, default for others).
*/
export function StatCardSkeleton() {
const { websiteTheme } = useWebsiteTheme()
+12
View File
@@ -1,3 +1,7 @@
// ── Dashboard: Stat Card Component ──
// Animated metric card with value counter, icon, trend indicator, optional sparkline chart,
// conversion rate bar, and Spidey-theme hover effects.
"use client"
import { useEffect, useState } from "react"
@@ -19,6 +23,7 @@ interface StatCardProps {
conversionRate?: number
}
/** Per-title color configuration for icon backgrounds, text, glow, and sparkline */
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
@@ -28,11 +33,13 @@ const cardColors: Record<string, { bg: string; text: string; glow: string; accen
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
}
/** Compute the nearest round goal above the max value */
function computeGoal(max: number): number {
if (max <= 0) return 100
return Math.ceil(max / 100) * 100 || 100
}
/** Generate a smooth Catmull-Rom SVG path string from points */
function smoothPath(points: { x: number; y: number }[]): string {
if (points.length === 0) return ""
if (points.length === 1) return `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
@@ -50,6 +57,9 @@ function smoothPath(points: { x: number; y: number }[]): string {
return d
}
/**
* StatCard animated dashboard metric with counting effect, sparkline, and themed decoration.
*/
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
const { websiteTheme } = useWebsiteTheme()
const color = cardColors[title] ?? cardColors["Total Leads"]
@@ -58,6 +68,7 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const [display, setDisplay] = useState(0)
const target = typeof value === "number" ? value : 0
// ── Animated counter: increments from 0 to target over 1 second ──
useEffect(() => {
if (!isNumeric) return
const duration = 1000
@@ -76,6 +87,7 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
return () => clearInterval(timer)
}, [target, isNumeric])
// ── Build sparkline SVG path and grid lines from monthly breakdown data ──
function buildSparklineSvg(): { path: string; area: string; goal: number; gridLines: { y: number }[] } {
if (!monthlyBreakdown || !sparklineField) return { path: "", area: "", goal: 100, gridLines: [] }
const values = monthlyBreakdown.map((m) => m[sparklineField]).reverse()
+13 -2
View File
@@ -1,3 +1,8 @@
// ── Layout: App Shell Component ──
// Root layout wrapper that combines Sidebar, Topbar, and page content.
// Manages sidebar collapse state (persisted to localStorage), mobile sidebar toggle,
// Spidey theme background decoration, and page transition animations.
"use client"
import { useState, useEffect } from "react"
@@ -11,23 +16,29 @@ interface AppShellProps {
children: React.ReactNode
}
/**
* AppShell top-level layout component.
* Handles sidebar collapsed/expanded state (persisted), mobile sidebar toggle,
* Spidey-theme background patterns/stripes, and page-level route transitions.
*/
export function AppShell({ children }: AppShellProps) {
const { websiteTheme } = useWebsiteTheme()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname()
// Close mobile sidebar on route change
// ── Close mobile sidebar on route change ──
useEffect(() => {
setMobileOpen(false)
}, [pathname])
// Persist sidebar state
// ── Restore sidebar collapsed state from localStorage ──
useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed")
if (saved) setSidebarCollapsed(saved === "true")
}, [])
// ── Toggle sidebar and persist preference ──
const toggleSidebar = () => {
const next = !sidebarCollapsed
setSidebarCollapsed(next)
+7
View File
@@ -1,5 +1,12 @@
// ── Layout: CRM Icon SVG Component ──
// Decorative SVG icon representing the CRM, with dark/light mode fill adaptation.
"use client"
/**
* CrmIcon a stylized SVG icon for the CRM brand.
* Uses CSS-driven fills that switch between light and dark mode.
*/
export function CrmIcon() {
return (
<svg
@@ -1,5 +1,12 @@
// ── Layout: Sidebar Name Logo SVG ──
// SVG rendering of the "BlackCipher" wordmark logo for the sidebar header.
"use client"
/**
* SidebarNameLogo renders the "BlackCipher" brand text as an inline SVG.
* Uses fill colors that adapt to light/dark mode.
*/
export function SidebarNameLogo() {
return (
<svg
+9
View File
@@ -1,3 +1,7 @@
// ── Layout: Sidebar Component ──
// Main application sidebar with navigation links, user info, system monitor,
// collapsible mode, and mobile overlay. Shows unread chat indicator on the Chats link.
"use client"
import { useState } from "react"
@@ -27,6 +31,7 @@ import { useUser } from "@/providers/user-provider"
import { useNotifications } from "@/providers/notification-provider"
import { FacebookAccountsDialog } from "@/components/settings/facebook-accounts-dialog"
/** Navigation link definitions with optional role gating */
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/leads", label: "Leads", icon: Users },
@@ -44,6 +49,10 @@ interface SidebarProps {
onMobileClose: () => void
}
/**
* Sidebar collapsible sidebar with navigation items, Facebook accounts button (admin),
* user avatar/name, system monitor, and mobile slide-out overlay.
*/
export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: SidebarProps) {
const pathname = usePathname()
const { user } = useUser()
+11
View File
@@ -1,14 +1,25 @@
// ── Layout: System Monitor Component ──
// Displays live RAM and CPU usage in the sidebar footer.
// Polls /api/system/monitor every 3 seconds and highlights values that exceed thresholds.
"use client"
import { useState, useEffect } from "react"
/** Warning threshold for RAM usage (MB) */
const RAM_LIMIT_MB = 8192
/** Warning threshold for CPU usage (%) */
const CPU_LIMIT_PCT = 400
interface SystemMonitorProps {
collapsed: boolean
}
/**
* SystemMonitor fetches and displays RAM (MB) and CPU (%) metrics from the API.
* In collapsed mode, shows just the numbers; in expanded mode, shows labels and thresholds.
* Values turn red when they exceed their warning limits.
*/
export function SystemMonitor({ collapsed }: SystemMonitorProps) {
const [rssMB, setRssMB] = useState(0)
const [cpuPct, setCpuPct] = useState(0)
+13 -1
View File
@@ -1,3 +1,7 @@
// ── Layout: Topbar Component ──
// Sticky header bar with logo, mobile menu toggle, search input, theme toggle,
// bug report button, notification dropdown, and user profile dropdown.
"use client";
import { useState, useEffect } from "react";
@@ -39,6 +43,11 @@ interface TopbarProps {
onMenuClick: () => void;
}
/**
* Topbar the application's top navigation bar.
* Includes logo, search, theme toggle, bug report, notifications dropdown,
* and user profile dropdown with logout.
*/
export function Topbar({ onMenuClick }: TopbarProps) {
const router = useRouter();
const { theme, setTheme } = useTheme();
@@ -50,11 +59,13 @@ export function Topbar({ onMenuClick }: TopbarProps) {
const [searchOpen, setSearchOpen] = useState(false);
const [bugModalOpen, setBugModalOpen] = useState(false);
// ── Mount guard to avoid hydration mismatch on theme icon ──
useEffect(() => {
setMounted(true);
}, []);
if (!user) return null;
/** Format a timestamp as a human-readable relative time string */
function formatTimeAgo(ts: string): string {
const diff = Date.now() - new Date(ts).getTime();
const mins = Math.floor(diff / 60000);
@@ -73,8 +84,9 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase();
return (
// ── Main header container ──
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
{/* Logo */}
{/* ── Logo section ── */}
<div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" && <CaptainAmericaShield />}
<span className="text-primary font-bold text-sm tracking-wide">
@@ -1,3 +1,6 @@
// ── Leads: Delete Lead Confirmation Dialog ──
// AlertDialog asking the user to confirm lead deletion with a warning about irreversible data loss.
"use client"
import {
@@ -18,6 +21,10 @@ interface DeleteLeadDialogProps {
lead: Lead
}
/**
* DeleteLeadDialog confirmation dialog before deleting a lead.
* Warns that the action is irreversible and will remove all associated data.
*/
export function DeleteLeadDialog({ open, onOpenChange, lead }: DeleteLeadDialogProps) {
const handleDelete = () => {
console.log("Delete lead:", lead.id)
@@ -1,3 +1,7 @@
// ── Leads: Actions Dropdown Component ──
// Three-dot menu with View, Edit, Assign, and Delete actions for a lead row.
// Opens the edit form dialog and delete confirmation dialog.
"use client"
import { useState } from "react"
@@ -20,6 +24,10 @@ interface LeadActionsDropdownProps {
lead: Lead
}
/**
* LeadActionsDropdown context menu for a lead row.
* Provides View (link), Edit (dialog), Assign, and Delete (confirmation dialog) options.
*/
export function LeadActionsDropdown({ lead }: LeadActionsDropdownProps) {
const [editOpen, setEditOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
@@ -1,3 +1,7 @@
// ── Leads: Lead Details Card Component ──
// Displays full lead information in a card: company, contact, email, phone, source,
// status, created/updated dates, description, and assigned user.
"use client"
import { motion } from "framer-motion"
@@ -21,6 +25,10 @@ interface LeadDetailsCardProps {
lead: Lead
}
/**
* LeadDetailsCard animated card showing all lead metadata in a 2-column grid.
* Includes an assigned-user section and an optional description block.
*/
export function LeadDetailsCard({ lead }: LeadDetailsCardProps) {
const fields = [
{ icon: Building2, label: "Company", value: lead.companyName },
+10
View File
@@ -1,3 +1,7 @@
// ── Leads: Create/Edit Lead Dialog ──
// Full form dialog for creating or editing a lead with company/contact info,
// source, status, description, and assignment. Uses react-hook-form with Zod validation.
"use client"
import { useState, useEffect } from "react"
@@ -34,6 +38,7 @@ import { Lead, LeadStatus, User } from "@/types"
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
import { useNotifications } from "@/providers/notification-provider"
/** Zod schema for lead form validation */
const leadFormSchema = z.object({
companyName: z.string().min(1, "Company name is required"),
contactName: z.string().min(1, "Contact name is required"),
@@ -54,6 +59,11 @@ interface LeadFormDialogProps {
onSuccess?: () => void
}
/**
* LeadFormDialog modal for creating or editing a lead.
* Loads the user list for assignment on mount.
* Creates or updates the lead via API and triggers a notification on success.
*/
export function LeadFormDialog({ open, onOpenChange, lead, onSuccess }: LeadFormDialogProps) {
const isEditing = !!lead
const { addNotification } = useNotifications()
@@ -1,9 +1,13 @@
// ── Leads: Status Badge Component ──
// Color-coded badge component for lead status (Open, Contacted, Pending, Closed, Ignored).
"use client"
import { Badge } from "@/components/ui/badge"
import { LeadStatus } from "@/types"
import { cn } from "@/lib/utils"
/** Color and label configuration for each lead status */
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
open: {
label: "Open",
@@ -32,6 +36,9 @@ interface LeadStatusBadgeProps {
className?: string
}
/**
* LeadStatusBadge renders a colored pill badge with a dot icon for the given lead status.
*/
export function LeadStatusBadge({ status, className }: LeadStatusBadgeProps) {
const config = statusConfig[status]
return (

Some files were not shown because too many files have changed in this diff Show More