Compare commits
109 Commits
06912f3e59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c855579b | |||
| 3a348e3616 | |||
| dba4c84cd5 | |||
| d77ff2b965 | |||
| 0bc3ca58ed | |||
| d793604e92 | |||
| bc83af8e00 | |||
| 80dee367e8 | |||
| da99b695be | |||
| dd6767980a | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| c0cb715ced | |||
| 1269f6cce8 | |||
| 370f935cbb | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| e8d80c3a16 | |||
| 77cccba8a1 | |||
| 7a5e833722 | |||
| 1e8f979cf9 | |||
| 98145f0264 | |||
| 7a722a7d7e | |||
| 61e9fac352 | |||
| 878627a6ba | |||
| 2c3a8e5333 | |||
| d870fb3ac7 | |||
| f6d1d91fc6 | |||
| bc3e345a27 | |||
| 3c0e7d76ca | |||
| 39fb39db12 | |||
| cd37d5a987 | |||
| d138c60203 | |||
| 4d7a59c278 | |||
| aac9817ee7 | |||
| fa97abb5b6 | |||
| 3998285fd5 | |||
| 4c1db42873 | |||
| c428435f2f | |||
| 886a7efe91 | |||
| 1b5f244f28 | |||
| 46a3216386 | |||
| 8aba5c9ef4 | |||
| 06181625c3 | |||
| 08eba574f8 | |||
| a876f2d769 | |||
| fbb37d6777 | |||
| 214c4a1852 | |||
| c669ec0d04 | |||
| bf6fbe3ee4 | |||
| d6534eb338 | |||
| a08f23ab0f | |||
| bccba1434b | |||
| fe09d15359 | |||
| 2b8f2e7b59 | |||
| fb99b49686 | |||
| 75746f4cab | |||
| a022e3cca3 | |||
| 68483e9b60 | |||
| 21868fe336 | |||
| 77b246b565 | |||
| 34e64fe6c7 | |||
| ffa595451e | |||
| bd581739f9 | |||
| ec8207fca7 | |||
| ec4de03cf5 | |||
| dfba947aa4 | |||
| 9bd795e39a | |||
| 66ae711cf0 | |||
| 827b0598e6 | |||
| 650116872b | |||
| 5e3c12fa2d | |||
| 7a76841309 | |||
| 283c06f0cf | |||
| 60892d6151 | |||
| 131d4c8f94 | |||
| 1f448320be | |||
| 9ce9506e8e | |||
| ed2e1fc64d | |||
| 5feb95187c | |||
| a4d2ad143b | |||
| da702d6beb | |||
| 33a4f9dfa9 | |||
| 82e8ce8ae9 | |||
| 343f814569 | |||
| 4c09635d30 | |||
| 20a1744e7f | |||
| c9df7787d5 | |||
| fa39f2af9d | |||
| 9bbaf70145 | |||
| 5668a63370 | |||
| 3af622f53d | |||
| 1465016b56 | |||
| 906e37e845 | |||
| 52a489759f | |||
| 1f032dc098 | |||
| 1adb6544c2 | |||
| 56ff9995a2 | |||
| 33753a1291 | |||
| 9acbbb6a19 |
@@ -0,0 +1,58 @@
|
||||
name: Build & Auto-Repair
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-audit --no-fund
|
||||
|
||||
- name: Install Playwright
|
||||
run: npx playwright install chromium firefox
|
||||
|
||||
- name: Build check
|
||||
id: build
|
||||
continue-on-error: true
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run self-healing setup
|
||||
if: steps.build.outcome == 'failure'
|
||||
run: node scripts/setup.mjs --self-heal
|
||||
|
||||
- name: Run code repair agent
|
||||
if: steps.build.outcome == 'failure'
|
||||
env:
|
||||
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
|
||||
run: node scripts/code-repair-agent.mjs --ci
|
||||
|
||||
- name: Re-check build after repair
|
||||
id: rebuild
|
||||
continue-on-error: true
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Commit fixes
|
||||
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
|
||||
run: |
|
||||
git config user.name "CRM Repair Bot"
|
||||
git config user.email "bot@coastit.co.za"
|
||||
git add -A
|
||||
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
|
||||
git push
|
||||
|
||||
- name: Build failed — report
|
||||
if: steps.rebuild.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Build still failing after auto-repair. Manual intervention required."
|
||||
exit 1
|
||||
+9
-3
@@ -47,9 +47,6 @@ error-log
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# rust
|
||||
rust-ai/target/
|
||||
|
||||
# python
|
||||
browser-use-service/venv/
|
||||
browser-use-service/__pycache__/
|
||||
@@ -58,3 +55,12 @@ browser-use-service/.env
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.git
|
||||
.git_bak
|
||||
node_modules
|
||||
target
|
||||
# rust build artifacts
|
||||
rust-ai/target/
|
||||
|
||||
# browser-use-service generated files
|
||||
browser-use-service/*.txt
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 2,
|
||||
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
|
||||
"templates": {
|
||||
"data/stickers": {
|
||||
"template": "stickers.ts",
|
||||
"description": "Emoji-based sticker packs for media picker"
|
||||
},
|
||||
"data/constants": {
|
||||
"template": "constants.ts",
|
||||
"description": "App-wide constants (lead statuses, roles, event types)"
|
||||
},
|
||||
"lib/utils": {
|
||||
"template": "utils.ts",
|
||||
"description": "Utility functions (cn, formatters, helpers)"
|
||||
},
|
||||
"types/index": {
|
||||
"template": "types-index.ts",
|
||||
"description": "Core type definitions (User, Lead, DashboardStats)"
|
||||
},
|
||||
"hooks/use-media": {
|
||||
"template": "hooks/use-media.ts",
|
||||
"description": "Media query hook"
|
||||
},
|
||||
"hooks/use-debounce": {
|
||||
"template": "hooks/use-debounce.ts",
|
||||
"description": "Debounce hook"
|
||||
},
|
||||
"hooks/use-local-storage": {
|
||||
"template": "hooks/use-local-storage.ts",
|
||||
"description": "LocalStorage hook with SSR safety"
|
||||
}
|
||||
},
|
||||
"fallbackTemplates": {
|
||||
"@/data/*": "data-generic.ts",
|
||||
"@/lib/*": "lib-generic.ts",
|
||||
"@/hooks/*": "hook-generic.ts",
|
||||
"@/types/*": "types-generic.ts",
|
||||
"@/components/*": "component-generic.tsx",
|
||||
"@/utils/*": "utils-generic.ts"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ── Generic Component Template ────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/components/* imports. Customize as needed.
|
||||
|
||||
export default function GenericComponent() {
|
||||
return <div />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// ── App Constants ────────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
|
||||
|
||||
export const APP_NAME = "CoastIT CRM";
|
||||
export const APP_VERSION = "1.0.0";
|
||||
|
||||
export const LEAD_STATUSES = {
|
||||
new: { label: "New", color: "bg-blue-500" },
|
||||
contacted: { label: "Contacted", color: "bg-yellow-500" },
|
||||
qualified: { label: "Qualified", color: "bg-purple-500" },
|
||||
proposal: { label: "Proposal", color: "bg-orange-500" },
|
||||
won: { label: "Won", color: "bg-green-500" },
|
||||
lost: { label: "Lost", color: "bg-red-500" },
|
||||
} as const;
|
||||
|
||||
export const USER_ROLES = {
|
||||
super_admin: { label: "Super Admin", level: 1 },
|
||||
admin: { label: "Admin", level: 2 },
|
||||
sales: { label: "Sales", level: 3 },
|
||||
dev: { label: "Developer", level: 4 },
|
||||
} as const;
|
||||
|
||||
export const EVENT_TYPES = [
|
||||
"meeting",
|
||||
"call",
|
||||
"email",
|
||||
"task",
|
||||
"note",
|
||||
] as const;
|
||||
|
||||
export const EVENT_STATUSES = [
|
||||
"scheduled",
|
||||
"completed",
|
||||
"cancelled",
|
||||
"rescheduled",
|
||||
] as const;
|
||||
|
||||
export const AI_MODELS = {
|
||||
chat: "llama3.2:3b",
|
||||
scraper: "dolphin-llama3:8b",
|
||||
coder: "qwen2.5-coder:1.5b-base",
|
||||
} as const;
|
||||
|
||||
export const PAGINATION = {
|
||||
defaultLimit: 50,
|
||||
maxLimit: 200,
|
||||
} as const;
|
||||
|
||||
export const DATE_FORMATS = {
|
||||
short: "MMM d, yyyy",
|
||||
long: "MMMM d, yyyy",
|
||||
time: "h:mm a",
|
||||
datetime: "MMM d, yyyy h:mm a",
|
||||
} as const;
|
||||
@@ -0,0 +1,17 @@
|
||||
// ── Generic Data Module Template ─────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/data/* imports. Customize as needed.
|
||||
|
||||
export interface DataItem {
|
||||
id: string;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function getDataItems(): DataItem[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getDataItem(id: string): DataItem | undefined {
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// ── Generic Hook Template ────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/hooks/* imports. Customize as needed.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return [value, setValue];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// ── useDebounce Hook ──────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit to customize.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// ── useLocalStorage Hook ──────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit to customize.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
if (item) setStoredValue(JSON.parse(item));
|
||||
} catch {
|
||||
// corrupted data — use initial value
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
const setValue = (value: T | ((prev: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch {
|
||||
// storage full or disabled
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// ── useMedia Hook ─────────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit to customize.
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useMedia(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
setMatches(mql.matches);
|
||||
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||
mql.addEventListener("change", handler);
|
||||
return () => mql.removeEventListener("change", handler);
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ── Generic Lib Module Template ──────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/lib/* imports. Customize as needed.
|
||||
|
||||
export function libFunction(): void {
|
||||
// Placeholder
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// ── Sticker Packs ─────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||
|
||||
export interface Sticker {
|
||||
id: string;
|
||||
name: string;
|
||||
emoji?: string;
|
||||
svg?: string;
|
||||
}
|
||||
|
||||
export interface StickerPack {
|
||||
id: string;
|
||||
name: string;
|
||||
stickers: Sticker[];
|
||||
}
|
||||
|
||||
const reactionStickers: Sticker[] = [
|
||||
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
|
||||
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
|
||||
{ id: "heart", name: "Heart", emoji: "❤️" },
|
||||
{ id: "laugh", name: "Laugh", emoji: "😂" },
|
||||
{ id: "wow", name: "Wow", emoji: "😮" },
|
||||
{ id: "sad", name: "Sad", emoji: "😢" },
|
||||
{ id: "angry", name: "Angry", emoji: "😡" },
|
||||
{ id: "clap", name: "Clap", emoji: "👏" },
|
||||
{ id: "fire", name: "Fire", emoji: "🔥" },
|
||||
{ id: "100", name: "100", emoji: "💯" },
|
||||
{ id: "eyes", name: "Eyes", emoji: "👀" },
|
||||
{ id: "pray", name: "Pray", emoji: "🙏" },
|
||||
];
|
||||
|
||||
const animalStickers: Sticker[] = [
|
||||
{ id: "cat", name: "Cat", emoji: "🐱" },
|
||||
{ id: "dog", name: "Dog", emoji: "🐶" },
|
||||
{ id: "panda", name: "Panda", emoji: "🐼" },
|
||||
{ id: "fox", name: "Fox", emoji: "🦊" },
|
||||
{ id: "bear", name: "Bear", emoji: "🐻" },
|
||||
{ id: "koala", name: "Koala", emoji: "🐨" },
|
||||
{ id: "tiger", name: "Tiger", emoji: "🐯" },
|
||||
{ id: "lion", name: "Lion", emoji: "🦁" },
|
||||
{ id: "monkey", name: "Monkey", emoji: "🐵" },
|
||||
{ id: "frog", name: "Frog", emoji: "🐸" },
|
||||
{ id: "penguin", name: "Penguin", emoji: "🐧" },
|
||||
{ id: "bird", name: "Bird", emoji: "🐦" },
|
||||
];
|
||||
|
||||
const foodStickers: Sticker[] = [
|
||||
{ id: "pizza", name: "Pizza", emoji: "🍕" },
|
||||
{ id: "burger", name: "Burger", emoji: "🍔" },
|
||||
{ id: "taco", name: "Taco", emoji: "🌮" },
|
||||
{ id: "sushi", name: "Sushi", emoji: "🍣" },
|
||||
{ id: "ramen", name: "Ramen", emoji: "🍜" },
|
||||
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
|
||||
{ id: "cake", name: "Cake", emoji: "🍰" },
|
||||
{ id: "coffee", name: "Coffee", emoji: "☕" },
|
||||
{ id: "beer", name: "Beer", emoji: "🍺" },
|
||||
{ id: "wine", name: "Wine", emoji: "🍷" },
|
||||
{ id: "donut", name: "Donut", emoji: "🍩" },
|
||||
{ id: "cookie", name: "Cookie", emoji: "🍪" },
|
||||
];
|
||||
|
||||
const activityStickers: Sticker[] = [
|
||||
{ id: "football", name: "Football", emoji: "⚽" },
|
||||
{ id: "basketball", name: "Basketball", emoji: "🏀" },
|
||||
{ id: "tennis", name: "Tennis", emoji: "🎾" },
|
||||
{ id: "running", name: "Running", emoji: "🏃" },
|
||||
{ id: "swimming", name: "Swimming", emoji: "🏊" },
|
||||
{ id: "cycling", name: "Cycling", emoji: "🚴" },
|
||||
{ id: "gym", name: "Gym", emoji: "🏋️" },
|
||||
{ id: "yoga", name: "Yoga", emoji: "🧘" },
|
||||
{ id: "music", name: "Music", emoji: "🎵" },
|
||||
{ id: "party", name: "Party", emoji: "🎉" },
|
||||
{ id: "gift", name: "Gift", emoji: "🎁" },
|
||||
{ id: "trophy", name: "Trophy", emoji: "🏆" },
|
||||
];
|
||||
|
||||
const stickerPacks: StickerPack[] = [
|
||||
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
|
||||
{ id: "animals", name: "Animals", stickers: animalStickers },
|
||||
{ id: "food", name: "Food & Drink", stickers: foodStickers },
|
||||
{ id: "activities", name: "Activities", stickers: activityStickers },
|
||||
];
|
||||
|
||||
export function getStickerPacks(): StickerPack[] {
|
||||
return stickerPacks;
|
||||
}
|
||||
|
||||
export function getStickerPack(id: string): StickerPack | undefined {
|
||||
return stickerPacks.find((p) => p.id === id);
|
||||
}
|
||||
|
||||
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
|
||||
const pack = getStickerPack(packId);
|
||||
return pack?.stickers.find((s) => s.id === stickerId);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// ── Generic Types Template ────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/types/* imports. Customize as needed.
|
||||
|
||||
export type GenericType = Record<string, unknown>;
|
||||
|
||||
export interface GenericInterface {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// ── Type Definitions ──────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
|
||||
|
||||
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role: UserRole;
|
||||
active: boolean;
|
||||
avatar: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Lead {
|
||||
id: string;
|
||||
companyName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
source: string;
|
||||
description: string;
|
||||
status: LeadStatus;
|
||||
assignedUserId: string | null;
|
||||
assignedUser: User | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type LeadStatus =
|
||||
| "new"
|
||||
| "contacted"
|
||||
| "qualified"
|
||||
| "proposal"
|
||||
| "won"
|
||||
| "lost";
|
||||
|
||||
export interface DashboardStats {
|
||||
totalLeads: number;
|
||||
newLeads: number;
|
||||
contactedLeads: number;
|
||||
qualifiedLeads: number;
|
||||
proposalLeads: number;
|
||||
wonLeads: number;
|
||||
lostLeads: number;
|
||||
conversionRate: number;
|
||||
monthlyBreakdown: {
|
||||
label: string;
|
||||
total: number;
|
||||
new: number;
|
||||
contacted: number;
|
||||
qualified: number;
|
||||
proposal: number;
|
||||
won: number;
|
||||
lost: number;
|
||||
}[];
|
||||
leadsPerMonth: { label: string; leads: number; won: number }[];
|
||||
recentLeads: Lead[];
|
||||
statusDistribution: { name: string; value: number; color: string }[];
|
||||
periodLabel: string;
|
||||
trends: Record<string, { pct: number; up: boolean }>;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
leadId: string;
|
||||
userId: string;
|
||||
authorName: string;
|
||||
authorAvatar: string;
|
||||
authorRole: UserRole;
|
||||
note: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export type NotificationType =
|
||||
| "lead_created"
|
||||
| "lead_status_changed"
|
||||
| "lead_assigned"
|
||||
| "chat_message"
|
||||
| "note_added"
|
||||
| "event_scheduled";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
senderAvatar: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
participants: { id: string; name: string; avatar: string; role: string }[];
|
||||
lastMessage: string;
|
||||
lastMessageTime: string;
|
||||
unread: number;
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export type EventType = "meeting" | "call" | "email" | "task" | "note";
|
||||
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
userId: string;
|
||||
participantId: string | null;
|
||||
developerId: string | null;
|
||||
leadId: string | null;
|
||||
conversationId: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
participantNotes: string | null;
|
||||
eventType: EventType;
|
||||
startTime: string;
|
||||
endTime: string | null;
|
||||
durationMinutes: number | null;
|
||||
status: EventStatus;
|
||||
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;
|
||||
lead: { id: string; companyName: string; contactName: string } | null;
|
||||
clientName: string | null;
|
||||
clientEmail: string | null;
|
||||
clientPhone: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// ── Generic Utils Template ────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
// Placeholder for @/utils/* imports. Customize as needed.
|
||||
|
||||
export function utilsFunction(): void {
|
||||
// Placeholder
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// ── Utility Functions ────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
|
||||
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||
}
|
||||
|
||||
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||
}
|
||||
|
||||
export function truncate(str: string, length: number): string {
|
||||
if (str.length <= length) return str;
|
||||
return str.slice(0, length - 3) + "...";
|
||||
}
|
||||
|
||||
export function generateId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
delay: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||
fn: T,
|
||||
limit: number
|
||||
): (...args: Parameters<T>) => void {
|
||||
let inThrottle = false;
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
fn(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
|
||||
return fn().catch((err) =>
|
||||
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
rust-ai/target/** linguist-generated=true -diff -merge
|
||||
@@ -1,60 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node_modules_old
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# runtime data
|
||||
data/
|
||||
data/ai/
|
||||
data/ai/jobs.jsonl
|
||||
|
||||
# logs
|
||||
*.log
|
||||
*.out
|
||||
error-log
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# rust
|
||||
rust-ai/target/
|
||||
|
||||
# python
|
||||
browser-use-service/venv/
|
||||
browser-use-service/__pycache__/
|
||||
browser-use-service/.env
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,44 +0,0 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
## Install
|
||||
- Jose
|
||||
- Node.js
|
||||
- Add emoji pakages
|
||||
- Install postgres for databases
|
||||
- Typescript
|
||||
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@@ -1,56 +0,0 @@
|
||||
# CRM Frontend - Quick Start
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js >= 18.17 (recommended: 20.9+ or 22.x)
|
||||
- npm 9+
|
||||
|
||||
## Install & Run
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The app runs at **http://localhost:3000**
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `/login` | Login page |
|
||||
| `/` | Dashboard |
|
||||
| `/leads` | Lead management table |
|
||||
| `/leads/:id` | Lead detail view |
|
||||
| `/users` | User management |
|
||||
| `/settings` | Settings (Company, Theme, Notifications) |
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Next.js 15 + React 18
|
||||
- TypeScript + Tailwind CSS v4
|
||||
- shadcn/ui components (Radix primitives)
|
||||
- TanStack Table v8
|
||||
- Recharts
|
||||
- Framer Motion
|
||||
- Lucide Icons
|
||||
|
||||
## If Node.js is < 20.9
|
||||
|
||||
The eslint-config-next package requires Node.js >= 20.9. If your Node.js is older:
|
||||
|
||||
```bash
|
||||
npm install --legacy-peer-deps
|
||||
```
|
||||
|
||||
Or upgrade Node.js via [nvm](https://github.com/nvm-sh/nvm):
|
||||
|
||||
```bash
|
||||
nvm install 22
|
||||
nvm use 22
|
||||
```
|
||||
@@ -1,293 +0,0 @@
|
||||
import http from "node:http"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = path.resolve(__dirname, "..")
|
||||
|
||||
// ── Load .env.local ──────────────────────────────────────────────
|
||||
try {
|
||||
const envPath = path.join(ROOT, ".env.local")
|
||||
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||
for (const line of envContent.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
const eqIdx = trimmed.indexOf("=")
|
||||
if (eqIdx === -1) continue
|
||||
const k = trimmed.substring(0, eqIdx).trim()
|
||||
let v = trimmed.substring(eqIdx + 1).trim()
|
||||
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
|
||||
if (!process.env[k]) process.env[k] = v
|
||||
}
|
||||
console.log("Loaded .env.local")
|
||||
} catch {
|
||||
// .env.local may not exist, ignore
|
||||
}
|
||||
|
||||
// ── Config from env ─────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||
const MODEL = process.env.AI_MODEL || "sam860/dolphin3-llama3.2:3b"
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
||||
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||
|
||||
// ── Job loading ─────────────────────────────────────────────────
|
||||
function loadJobs() {
|
||||
try {
|
||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||
const jobs = content
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
try {
|
||||
return JSON.parse(l)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
console.log(`Loaded ${jobs.length} job categories`)
|
||||
return jobs
|
||||
} catch (e) {
|
||||
console.error(`Failed to load jobs from ${JOBS_PATH}:`, e.message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// ── ai.md management ────────────────────────────────────────────
|
||||
function readInstructions() {
|
||||
try {
|
||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function writeInstructions(content) {
|
||||
fs.writeFileSync(AI_MD_PATH, content, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
function appendToImprovementLog(entry) {
|
||||
const current = readInstructions()
|
||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||
const logSection = "\n## Improvement Log"
|
||||
const logIndex = current.indexOf(logSection)
|
||||
if (logIndex !== -1) {
|
||||
const afterLog = current.substring(logIndex + logSection.length)
|
||||
const nextSectionIndex = afterLog.search(/\n## /)
|
||||
if (nextSectionIndex !== -1) {
|
||||
const insertAt = logIndex + logSection.length + nextSectionIndex
|
||||
const updated = current.substring(0, insertAt) + logEntry + "\n" + current.substring(insertAt)
|
||||
writeInstructions(updated)
|
||||
return updated
|
||||
}
|
||||
writeInstructions(current + logEntry + "\n")
|
||||
return current + logEntry + "\n"
|
||||
}
|
||||
const updated = current + `\n${logSection}\n${logEntry}\n`
|
||||
writeInstructions(updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
// ── Chat handler ────────────────────────────────────────────────
|
||||
async function handleChat(userMessage, userId, userRole) {
|
||||
const jobs = loadedJobs
|
||||
const instructions = readInstructions()
|
||||
|
||||
const jobList = jobs
|
||||
.map((j) => `- ${j.job_title} (${j.industry}): ${j.description}`)
|
||||
.join("\n")
|
||||
|
||||
const systemPrompt = `You are a Sales AI Assistant for Coast IT CRM. Your role is to help salespeople with tips, strategies, and guidance.
|
||||
|
||||
Available job categories to target:
|
||||
${jobList}
|
||||
|
||||
Current instructions:
|
||||
${instructions}
|
||||
|
||||
Provide concise, actionable sales advice. When asked about a specific job category, give targeted tips on finding and engaging prospects in that field. Keep responses under 300 words unless asked for detail.`
|
||||
|
||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
stream: false,
|
||||
options: { temperature: 0.7, num_predict: 1024 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!ollamaRes.ok) {
|
||||
const text = await ollamaRes.text()
|
||||
throw new Error(`Ollama error (${ollamaRes.status}): ${text}`)
|
||||
}
|
||||
|
||||
const data = await ollamaRes.json()
|
||||
const responseText = data.message?.content || ""
|
||||
|
||||
// Try to persist to DB (best-effort)
|
||||
try {
|
||||
if (pgPool && userId) {
|
||||
await pgPool.query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
[crypto.randomUUID(), userId, userRole || "sales", userMessage, responseText]
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// table might not exist, ignore
|
||||
}
|
||||
|
||||
return responseText
|
||||
}
|
||||
|
||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||
let pgPool = null
|
||||
async function initPg() {
|
||||
if (!DATABASE_URL) return
|
||||
try {
|
||||
const { default: pg } = await import("pg")
|
||||
pgPool = new pg.Pool({ connectionString: DATABASE_URL, max: 5 })
|
||||
await pgPool.query("SELECT 1")
|
||||
console.log("Connected to PostgreSQL")
|
||||
} catch (e) {
|
||||
console.warn("PostgreSQL unavailable (AI convos won't be saved):", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Request router ─────────────────────────────────────────────
|
||||
const loadedJobs = loadJobs()
|
||||
|
||||
function sendJSON(res, status, data) {
|
||||
res.writeHead(status, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify(data))
|
||||
}
|
||||
|
||||
function parseBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = ""
|
||||
req.on("data", (chunk) => (body += chunk))
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(JSON.parse(body))
|
||||
} catch {
|
||||
reject(new Error("Invalid JSON"))
|
||||
}
|
||||
})
|
||||
req.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function parseURL(req) {
|
||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
// CORS
|
||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const { pathname } = parseURL(req)
|
||||
|
||||
try {
|
||||
// GET /health
|
||||
if (req.method === "GET" && pathname === "/health") {
|
||||
return sendJSON(res, 200, { status: "ok", model: MODEL })
|
||||
}
|
||||
|
||||
// GET /ai/jobs
|
||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||
}
|
||||
|
||||
// GET /ai/instructions
|
||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||
const instructions = readInstructions()
|
||||
return sendJSON(res, 200, { success: true, instructions })
|
||||
}
|
||||
|
||||
// POST /ai/instructions
|
||||
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||
const body = await parseBody(req)
|
||||
if (body.content) {
|
||||
writeInstructions(body.content)
|
||||
} else if (body.entry) {
|
||||
appendToImprovementLog(body.entry)
|
||||
}
|
||||
return sendJSON(res, 200, {
|
||||
success: true,
|
||||
instructions: readInstructions(),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /ai/chat
|
||||
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||
const startTime = Date.now()
|
||||
const chunks = []
|
||||
req.on("data", c => chunks.push(c))
|
||||
req.on("end", () => {
|
||||
const rawBody = Buffer.concat(chunks).toString()
|
||||
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
|
||||
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
|
||||
try {
|
||||
const body = JSON.parse(rawBody)
|
||||
// Continue processing
|
||||
processRequest(req, res, body, startTime)
|
||||
} catch {
|
||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Separate handler
|
||||
async function processRequest(req, res, body, startTime) {
|
||||
const { message, user_id, user_role } = body
|
||||
|
||||
if (!message) {
|
||||
return sendJSON(res, 400, { error: "message is required" })
|
||||
}
|
||||
|
||||
const validRoles = ["sales", "admin", "super_admin"]
|
||||
if (user_role && !validRoles.includes(user_role)) {
|
||||
return sendJSON(res, 403, { error: "Forbidden" })
|
||||
}
|
||||
|
||||
const response = await handleChat(message, user_id || "", user_role || "sales")
|
||||
return sendJSON(res, 200, { response })
|
||||
}
|
||||
|
||||
// 404
|
||||
sendJSON(res, 404, { error: "Not found" })
|
||||
} catch (err) {
|
||||
console.error("Request error:", err)
|
||||
sendJSON(res, 500, { error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||
console.log(` Model: ${MODEL}`)
|
||||
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||
})
|
||||
|
||||
initPg()
|
||||
@@ -1,445 +0,0 @@
|
||||
import os, json, asyncio, re, shutil, sqlite3, urllib.parse, random, logging
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import uvicorn
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI()
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3006", "http://127.0.0.1:3006"],
|
||||
allow_methods=["POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
PORT = int(os.getenv("PORT", "3008"))
|
||||
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||
CLASSIFY_MODEL = os.getenv("CLASSIFY_MODEL", "dolphin-llama3:8b")
|
||||
|
||||
FX_PROFILE = os.getenv('FX_PROFILE', '')
|
||||
|
||||
BROAD_KEYWORDS = [
|
||||
"website", "web design", "web develop", "web dev",
|
||||
"build my website", "build a website", "create a website",
|
||||
"need web", "looking for web", "new website",
|
||||
"landing page", "wordpress",
|
||||
"need a website", "my website", "business website",
|
||||
"need a designer", "help with my website",
|
||||
"redesign", "update my website",
|
||||
"looking for", "need a", "need an", "looking to",
|
||||
"help me build", "create my", "build me",
|
||||
"design my", "make me a", "would like",
|
||||
"need someone", "hire a", "looking to hire",
|
||||
"want someone", "need help with",
|
||||
]
|
||||
|
||||
def kw_match(text: str) -> bool:
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in BROAD_KEYWORDS)
|
||||
|
||||
FB_SEARCHES = [
|
||||
"looking for web developer",
|
||||
"need a website designed",
|
||||
"need website built South Africa",
|
||||
"need someone to build my website",
|
||||
"need web designer",
|
||||
"looking for someone to create website",
|
||||
"need ecommerce website built",
|
||||
"want to hire web developer",
|
||||
"website quote please",
|
||||
"need wordpress website",
|
||||
"I need a website for my business",
|
||||
"need a site for my business",
|
||||
]
|
||||
|
||||
VIEWPORTS = [
|
||||
{'width': 1280, 'height': 800},
|
||||
{'width': 1366, 'height': 768},
|
||||
{'width': 1440, 'height': 900},
|
||||
{'width': 1536, 'height': 864},
|
||||
{'width': 1920, 'height': 1080},
|
||||
]
|
||||
|
||||
async def get_fb_cookies(profile_path: str | None = None):
|
||||
cookie_db_path = profile_path or FX_PROFILE
|
||||
if not cookie_db_path:
|
||||
logger.warning("No profile path provided")
|
||||
return []
|
||||
cookie_db = os.path.join(cookie_db_path, 'cookies.sqlite')
|
||||
if not os.path.exists(cookie_db):
|
||||
logger.warning("Cookie DB not found at %s", cookie_db)
|
||||
return []
|
||||
tmp = os.path.join(os.path.dirname(cookie_db), f'cookies_tmp_{os.getpid()}.sqlite')
|
||||
for attempt in range(3):
|
||||
try:
|
||||
shutil.copy2(cookie_db, tmp)
|
||||
break
|
||||
except PermissionError:
|
||||
if attempt < 2:
|
||||
await asyncio.sleep(0.5)
|
||||
else:
|
||||
logger.error("Failed to copy cookie DB after 3 attempts")
|
||||
return []
|
||||
try:
|
||||
conn = sqlite3.connect(tmp)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT name, value, host, path FROM moz_cookies WHERE host LIKE '%facebook.com'")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
return [{
|
||||
"name": name, "value": value,
|
||||
"domain": host if host.startswith('.') else f'.{host}',
|
||||
"path": path,
|
||||
"httpOnly": True, "secure": True, "sameSite": "Lax",
|
||||
} for name, value, host, path in rows]
|
||||
except Exception as e:
|
||||
logger.error("Cookie DB read error: %s", e)
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
WEEKDAY_ORDER = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
|
||||
|
||||
def _parse_fb_date(block: list[str]) -> str:
|
||||
for l in block:
|
||||
l = l.strip()
|
||||
m = re.match(r'(\d+)\s*(h|hr|hrs|hour|hours)\s*ago', l)
|
||||
if m:
|
||||
hours = int(m.group(1))
|
||||
d = datetime.now() - timedelta(hours=hours)
|
||||
return d.strftime('%Y-%m-%d')
|
||||
if l.lower() == 'yesterday':
|
||||
d = datetime.now() - timedelta(days=1)
|
||||
return d.strftime('%Y-%m-%d')
|
||||
low = l.lower()
|
||||
if low in WEEKDAY_ORDER:
|
||||
day_idx = WEEKDAY_ORDER.index(low)
|
||||
today_idx = datetime.now().weekday()
|
||||
days_ago = (today_idx - day_idx) % 7
|
||||
d = datetime.now() - timedelta(days=days_ago)
|
||||
return d.strftime('%Y-%m-%d')
|
||||
for fmt in ['%Y-%m-%d', '%d %B %Y', '%B %d %Y', '%d %b %Y', '%b %d %Y', '%d %b', '%b %d']:
|
||||
try:
|
||||
dt = datetime.strptime(l, fmt)
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
except ValueError:
|
||||
pass
|
||||
return datetime.now().strftime('%Y-%m-%d')
|
||||
|
||||
def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
||||
lines = [l.strip() for l in raw.split('\n')]
|
||||
blocks = []
|
||||
cur = []
|
||||
fb_run = 0
|
||||
for l in lines:
|
||||
if 'facebook' in l.lower():
|
||||
fb_run += 1
|
||||
if cur and fb_run >= 2:
|
||||
blocks.append(cur)
|
||||
cur = []
|
||||
else:
|
||||
fb_run = 0
|
||||
if l and len(l) >= 15:
|
||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||
if len(words) >= 2:
|
||||
cur.append(l)
|
||||
if cur:
|
||||
blocks.append(cur)
|
||||
|
||||
posts = []
|
||||
seen_texts = set()
|
||||
for block in blocks:
|
||||
if not block:
|
||||
continue
|
||||
kw_indices = [i for i, l in enumerate(block) if kw_match(l)]
|
||||
if not kw_indices:
|
||||
continue
|
||||
i = kw_indices[0]
|
||||
start = max(0, i - 2)
|
||||
end = min(len(block), i + 5)
|
||||
snippet = ' '.join(block[start:end])
|
||||
if len(snippet) < 40:
|
||||
continue
|
||||
dekey = snippet[:80]
|
||||
if dekey in seen_texts:
|
||||
continue
|
||||
seen_texts.add(dekey)
|
||||
posts.append({
|
||||
"title": snippet[:300],
|
||||
"content": snippet[:1000],
|
||||
"author": block[start] if start < i else '',
|
||||
"url": url,
|
||||
"source": "facebook",
|
||||
"date": _parse_fb_date(block),
|
||||
})
|
||||
return posts
|
||||
|
||||
async def human_scroll(page, steps: int = None, total_delay: float = None):
|
||||
steps = steps or random.randint(2, 5)
|
||||
total_delay = total_delay or random.uniform(6, 18)
|
||||
step_delay = total_delay / steps
|
||||
for i in range(steps):
|
||||
scroll_dist = random.randint(200, 600)
|
||||
await page.evaluate(f"window.scrollBy(0, {scroll_dist})")
|
||||
await page.wait_for_timeout(int(step_delay * 1000))
|
||||
if random.random() < 0.3:
|
||||
await page.evaluate(f"window.scrollBy(0, -{random.randint(100, 300)})")
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
|
||||
async def random_idle(page):
|
||||
action = random.random()
|
||||
if action < 0.15:
|
||||
try:
|
||||
elems = await page.query_selector_all('a, span, div[role="button"]')
|
||||
if elems:
|
||||
el = random.choice(elems[:20])
|
||||
box = await el.bounding_box()
|
||||
if box:
|
||||
x = box['x'] + box['width'] / 2 + random.randint(-20, 20)
|
||||
y = box['y'] + box['height'] / 2 + random.randint(-10, 10)
|
||||
await page.mouse.move(x, y, steps=random.randint(20, 40))
|
||||
await page.wait_for_timeout(random.randint(500, 2000))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def search_facebook(page, query: str) -> list[dict]:
|
||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||
try:
|
||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||
|
||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
||||
|
||||
# Accidental overscroll: 10% chance to jump to bottom and back
|
||||
if random.random() < 0.1:
|
||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
await page.evaluate("window.scrollBy(0, -300)")
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
|
||||
if random.random() < 0.2:
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
||||
|
||||
if random.random() < 0.3:
|
||||
await random_idle(page)
|
||||
|
||||
raw = await page.evaluate('document.body.innerText')
|
||||
except Exception as e:
|
||||
logger.warning("Facebook search failed: %s", e)
|
||||
return []
|
||||
return _extract_posts_from_text(raw, url)
|
||||
|
||||
def cleanup_chrome():
|
||||
import subprocess, signal
|
||||
try:
|
||||
subprocess.run(["taskkill", "/F", "/IM", "chrome-headless-shell.exe"], capture_output=True, timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
||||
cleanup_chrome()
|
||||
fb_cookies = await get_fb_cookies(profile_path)
|
||||
if not fb_cookies:
|
||||
logger.warning("No Facebook cookies available")
|
||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No cookies available"}
|
||||
try:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch(
|
||||
headless=True,
|
||||
args=['--disable-blink-features=AutomationControlled']
|
||||
)
|
||||
viewport = random.choice(VIEWPORTS)
|
||||
context = await browser.new_context(
|
||||
user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:150.0) Gecko/20100101 Firefox/150.0',
|
||||
viewport=viewport,
|
||||
)
|
||||
|
||||
await context.add_init_script("""
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => false });
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
|
||||
window.chrome = { runtime: {} };
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
""")
|
||||
for c in fb_cookies:
|
||||
try:
|
||||
await context.add_cookies([c])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to inject cookie %s: %s", c.get('name'), e)
|
||||
|
||||
page = await context.new_page()
|
||||
# Navigate through google first for a legitimate Referer header
|
||||
await page.goto('https://www.google.com/', wait_until='domcontentloaded', timeout=15000)
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
await page.goto('https://www.facebook.com/', wait_until='domcontentloaded', timeout=30000)
|
||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
||||
|
||||
url = page.url
|
||||
if '/login' in url.lower():
|
||||
logger.warning("Facebook login page detected — account flagged")
|
||||
await browser.close()
|
||||
return {"success": False, "leads": [], "flagged": True, "flag_reason": "login_page", "error": "Facebook login page detected"}
|
||||
|
||||
# Browse feed like a human before searching
|
||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(8, 20))
|
||||
if random.random() < 0.25:
|
||||
await page.evaluate("window.scrollTo(0, 0)")
|
||||
await page.wait_for_timeout(random.randint(2000, 5000))
|
||||
await human_scroll(page, steps=random.randint(1, 2))
|
||||
|
||||
# False start: 30% chance to idle and leave without scraping (skipped when force=true)
|
||||
if not force and random.random() < 0.3:
|
||||
await page.wait_for_timeout(random.randint(8000, 20000))
|
||||
await browser.close()
|
||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||
|
||||
all_posts = []
|
||||
searches = random.sample(FB_SEARCHES, k=random.randint(3, 6))
|
||||
for i, query in enumerate(searches):
|
||||
try:
|
||||
posts = await search_facebook(page, query)
|
||||
all_posts.extend(posts)
|
||||
# Between searches: random break with possible small scroll
|
||||
if random.random() < 0.4:
|
||||
await page.evaluate(f"window.scrollBy(0, {random.randint(-300, 300)})")
|
||||
delay = random.uniform(8, 25)
|
||||
await page.wait_for_timeout(int(delay * 1000))
|
||||
# Tab switch: 15% chance after 2nd-3rd search
|
||||
if i == random.randint(1, 2) and random.random() < 0.15:
|
||||
new_page = await context.new_page()
|
||||
await new_page.goto('https://www.messenger.com/', wait_until='domcontentloaded', timeout=15000)
|
||||
await new_page.wait_for_timeout(random.randint(3000, 8000))
|
||||
await new_page.close()
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
except Exception as e:
|
||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||
|
||||
# Idle before closing — human would pause
|
||||
if random.random() < 0.5:
|
||||
await page.wait_for_timeout(random.randint(3000, 10000))
|
||||
|
||||
await browser.close()
|
||||
|
||||
seen = set()
|
||||
deduped = []
|
||||
for p in all_posts:
|
||||
key = p.get('content', '')[:100]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(p)
|
||||
|
||||
leads = deduped[:20]
|
||||
if leads:
|
||||
leads = await classify_leads(leads)
|
||||
|
||||
return {"success": True, "leads": leads[:15], "flagged": False, "flag_reason": None, "error": None}
|
||||
except Exception as e:
|
||||
logger.error("Facebook scrape failed: %s", e)
|
||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": str(e)}
|
||||
|
||||
async def ask_ollama(prompt: str) -> str:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=120) as c:
|
||||
r = await c.post(f"{OLLAMA_URL}/api/chat", json={
|
||||
"model": CLASSIFY_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You classify forum posts. Return only valid JSON."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"stream": False,
|
||||
"options": {"temperature": 0.05, "num_predict": 1024},
|
||||
})
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data["message"]["content"]
|
||||
|
||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||
if not results:
|
||||
return []
|
||||
briefs = [r["title"][:200] for r in results]
|
||||
prompt = f"""Classify each post as LEAD or NOT.
|
||||
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
||||
LEAD examples: "Need a website for my business", "Looking for web developer to build my site", "I need someone to create my website", "Want a new website for my company", "Looking for someone to design my WordPress site"
|
||||
|
||||
NOT LEAD:
|
||||
- Offering web design services: "I build websites", "I offer web design", "Affordable web design packages"
|
||||
- Already have a website and need marketing, SEO, content, video, link building, email marketing, affiliates
|
||||
- Recruiting employees, hiring staff, looking for business partners
|
||||
- Selling products, promoting services, affiliate offers
|
||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
||||
|
||||
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
||||
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
||||
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
||||
ai_succeeded = False
|
||||
try:
|
||||
raw = await ask_ollama(prompt)
|
||||
raw = raw.strip()
|
||||
if raw.startswith("```"):
|
||||
first_nl = raw.find('\n')
|
||||
if first_nl != -1:
|
||||
raw = raw[first_nl + 1:]
|
||||
if raw.endswith("```"):
|
||||
raw = raw[:-3]
|
||||
raw = raw.strip()
|
||||
answers = json.loads(raw)
|
||||
if isinstance(answers, list) and len(answers) == len(results):
|
||||
ai_succeeded = True
|
||||
filtered = []
|
||||
for i, ans in enumerate(answers):
|
||||
if isinstance(ans, str) and ans.lower() == 'yes':
|
||||
filtered.append(results[i])
|
||||
if filtered:
|
||||
return filtered[:10]
|
||||
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
||||
|
||||
if not ai_succeeded:
|
||||
strict_keywords = [
|
||||
"website", "web design", "web develop", "web dev",
|
||||
"build my website", "build a website", "create a website",
|
||||
"need web", "looking for web", "new website",
|
||||
"landing page", "wordpress",
|
||||
"need a website", "my website", "business website",
|
||||
"need a designer", "help with my website",
|
||||
"redesign", "update my website",
|
||||
]
|
||||
filtered = []
|
||||
for r in results:
|
||||
t = r['title'].lower()
|
||||
if not any(kw in t for kw in strict_keywords):
|
||||
continue
|
||||
if any(kw in t for kw in ['i build', 'i offer', 'i create', 'i am a', 'web developer available',
|
||||
'affordable web', 'web hosting', 'free website',
|
||||
'limited time', 'special offer', 'sign up now',
|
||||
'offer']):
|
||||
continue
|
||||
filtered.append(r)
|
||||
return filtered[:10]
|
||||
return []
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.post("/scrape/facebook")
|
||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
||||
result = await scrape_facebook(profile_path, force)
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||
@@ -1,164 +0,0 @@
|
||||
# CRM Database Schema — Enterprise RBAC + CRM
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Enterprise-grade PostgreSQL schema implementing **hierarchical Role-Based Access Control (RBAC)**
|
||||
with a complete CRM system. Designed for production deployments with strict security requirements,
|
||||
audit compliance, and scalability to millions of records.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hierarchical RBAC Model
|
||||
|
||||
Four roles arranged in a strict hierarchy by `hierarchy_level` (lower = higher privilege):
|
||||
|
||||
| Role | Level | Authority |
|
||||
|------|-------|-----------|
|
||||
| **SUPER_ADMIN** | 1 | Unrestricted — create/delete users, assign roles, override all permissions |
|
||||
| **ADMIN** | 2 | Operational — ban/suspend users, review reports, moderate activity. **Cannot create accounts.** |
|
||||
| **SALES_USER** | 3 | CRM operations — leads, customers, opportunities, communications |
|
||||
| **DEVELOPER** | 4 | Technical + CRM read access for post-sale project visibility |
|
||||
|
||||
### Enforcement via PostgreSQL Triggers
|
||||
|
||||
- **`enforce_create_user()`** (BEFORE INSERT ON users): Only SUPER_ADMIN (`hierarchy_level = 1`)
|
||||
can create new user accounts. Bootstrap case (`created_by IS NULL`) is allowed for initial setup.
|
||||
- **`enforce_role_assignment()`** (BEFORE INSERT ON user_roles): Only SUPER_ADMIN can assign roles.
|
||||
Prevents privilege escalation by checking hierarchy levels.
|
||||
|
||||
### Permission Model
|
||||
|
||||
Granular permissions stored in `permissions` table with `(resource, action)` pairs.
|
||||
`role_permissions` maps roles to permissions. The `has_permission(user_id, resource, action)`
|
||||
function provides application-layer authorization checks.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ban & Suspension System
|
||||
|
||||
Two separate tables for enforcement flexibility:
|
||||
|
||||
- **`banned_users`**: Permanent or time-limited bans. Supports reversal (by SUPER_ADMIN).
|
||||
Admins can create bans; SUPER_ADMIN can reverse or permanently delete banned users.
|
||||
- **`suspended_users`**: Time-limited suspensions (always has expiry). Admins can create
|
||||
and lift suspensions.
|
||||
|
||||
Both tables are fully audited via dedicated trigger functions.
|
||||
|
||||
---
|
||||
|
||||
## 3. Session & Login Security
|
||||
|
||||
- **`sessions`**: Stores hashed session tokens with expiry. Only one active session per
|
||||
token hash.
|
||||
- **`login_attempts`**: Captures every login attempt (successful or failed) with IP address
|
||||
and user agent. Used for brute-force detection and audit.
|
||||
- Users have `failed_login_attempts` and `locked_until` for account lockout policies.
|
||||
|
||||
---
|
||||
|
||||
## 4. Password Security
|
||||
|
||||
- All passwords hashed with **bcrypt (cost factor 12)**
|
||||
- `password_change_required` forces password change on first login (TRUE for all non-root
|
||||
test accounts)
|
||||
- Password hashes are opaque to the database — validation is application-layer only
|
||||
|
||||
---
|
||||
|
||||
## 5. UUID Primary Keys
|
||||
|
||||
- All tables use `UUID PRIMARY KEY DEFAULT uuid_generate_v4()`
|
||||
- Fixed UUIDs used for seed data and system entities for referential transparency
|
||||
- Prevents enumeration attacks and supports distributed ID generation
|
||||
|
||||
---
|
||||
|
||||
## 6. Audit Trail
|
||||
|
||||
Every mutation is logged to `audit_logs` via `audit_trigger_function()`:
|
||||
|
||||
- Captures `(table_name, record_id, action, old_data, new_data, changed_by, ip_address)`
|
||||
- Ban actions, suspension actions, and successful logins have dedicated audit triggers
|
||||
- Indexed on `(table_name, record_id)` for per-record lookup
|
||||
|
||||
Dedicated audit views:
|
||||
- `v_active_bans` — Currently active bans with user details
|
||||
- `v_active_suspensions` — Currently active suspensions
|
||||
- `v_user_permissions` — Flattened permission report per user
|
||||
|
||||
---
|
||||
|
||||
## 7. Third Normal Form (3NF)
|
||||
|
||||
Customer model uses the **discriminator pattern**:
|
||||
|
||||
```
|
||||
customers (parent — shared columns: status, owner, tags, score)
|
||||
├── customer_type = 'individual' → individual_customers
|
||||
└── customer_type = 'company' → company_customers
|
||||
```
|
||||
|
||||
All lookup tables are normalized. Contact information, addresses, and notes are
|
||||
separate 1:N child tables.
|
||||
|
||||
---
|
||||
|
||||
## 8. Indexing Strategy
|
||||
|
||||
- **Core indexes**: FK columns, status/sort/date columns, unique constraints
|
||||
- **Partial indexes**: `WHERE deleted_at IS NULL` on soft-delete tables,
|
||||
`WHERE is_active = TRUE` on bans/suspensions, `WHERE due_date < NOW()` on overdue invoices
|
||||
- **Full-text search**: GIN indexes on customers, leads, products, communications, tasks
|
||||
- **Composite indexes**: Common query patterns (e.g., `opportunities(owner_id, stage_id)`)
|
||||
|
||||
---
|
||||
|
||||
## 9. Soft Delete
|
||||
|
||||
- `deleted_at TIMESTAMPTZ` on all business tables
|
||||
- Partial unique indexes ignore soft-deleted rows (e.g., `WHERE deleted_at IS NULL`)
|
||||
- Audit logs capture the full row snapshot before soft-delete
|
||||
|
||||
---
|
||||
|
||||
## 10. Test Accounts
|
||||
|
||||
| Username | Password | Role |
|
||||
|----------|----------|------|
|
||||
| `superadmin_demo` | `[REDACTED]` | SUPER_ADMIN |
|
||||
| `admin_demo` | `[REDACTED]` | ADMIN |
|
||||
| `sales_demo` | `[REDACTED]` | SALES_USER |
|
||||
| `dev_demo` | `[REDACTED]` | DEVELOPER |
|
||||
|
||||
---
|
||||
|
||||
## 11. Migration Instructions
|
||||
|
||||
```bash
|
||||
# Create database
|
||||
psql -U postgres -c "CREATE DATABASE crm;"
|
||||
|
||||
# Run full migration
|
||||
psql -U postgres -d crm -f database/migrations/run_all.sql
|
||||
|
||||
# Or step by step:
|
||||
psql -U postgres -d crm -f database/migrations/001_schema.sql
|
||||
psql -U postgres -d crm -f database/migrations/002_seed.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. Security Rules Summary
|
||||
|
||||
| Rule | Enforcement |
|
||||
|------|-------------|
|
||||
| Only SUPER_ADMIN creates accounts | Trigger `trg_enforce_create_user` |
|
||||
| Only SUPER_ADMIN assigns roles | Trigger `trg_enforce_role_assignment` |
|
||||
| No privilege escalation | Level check in `enforce_role_assignment()` |
|
||||
| bcrypt password hashing | Application-layer (cost=12) |
|
||||
| Unique usernames/emails | Partial unique indexes |
|
||||
| Force password change on first login | `password_change_required` column |
|
||||
| Audit every login/logout | Trigger `trg_audit_login` on `login_attempts` |
|
||||
| Audit every ban action | Trigger `trg_audit_ban` on `banned_users` |
|
||||
| Audit every suspension action | Trigger `trg_audit_suspension` on `suspended_users` |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,401 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- CRM Database Seed Data — RBAC + Test Accounts + Reference Data
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- FIXED UUIDs FOR REFERENTIAL INTEGRITY
|
||||
-- ============================================================================
|
||||
|
||||
-- Role UUIDs
|
||||
-- SUPER_ADMIN=1, ADMIN=2, SALES_USER=3, DEVELOPER=4
|
||||
-- User UUIDs: SUPER_ADMIN=u0001, ADMIN=u0002, SALES=u0003, DEV=u0004
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. ROLES
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO roles (id, name, display_name, description, hierarchy_level, is_system) VALUES
|
||||
('00000001-0000-0000-0000-000000000000', 'SUPER_ADMIN', 'Super Administrator',
|
||||
'Highest authority. Full system access. Can create/delete users, assign roles, override all permissions.',
|
||||
1, TRUE),
|
||||
('00000002-0000-0000-0000-000000000000', 'ADMIN', 'Administrator',
|
||||
'Operational administration. Can ban/suspend users, review reports, moderate activity. Cannot create accounts.',
|
||||
2, TRUE),
|
||||
('00000003-0000-0000-0000-000000000000', 'SALES_USER', 'Sales Representative',
|
||||
'Day-to-day CRM operations. Manage leads, customers, opportunities, and communications.',
|
||||
3, TRUE),
|
||||
('00000004-0000-0000-0000-000000000000', 'DEVELOPER', 'Developer',
|
||||
'Internal development/testing with CRM read access. API testing, diagnostics, debugging. Can view projects and customer data post-sale.',
|
||||
4, TRUE)
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. PERMISSIONS — organised by category
|
||||
-- ============================================================================
|
||||
|
||||
-- USER MANAGEMENT (SUPER_ADMIN only)
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0010001-0000-0000-0000-000000000000', 'users', 'create', 'Create new user accounts', 'User Management'),
|
||||
('a0010002-0000-0000-0000-000000000000', 'users', 'read', 'View user details', 'User Management'),
|
||||
('a0010003-0000-0000-0000-000000000000', 'users', 'update', 'Update user details', 'User Management'),
|
||||
('a0010004-0000-0000-0000-000000000000', 'users', 'delete', 'Permanently delete user accounts', 'User Management'),
|
||||
('a0010005-0000-0000-0000-000000000000', 'users', 'assign_roles', 'Assign roles to users', 'User Management'),
|
||||
('a0010006-0000-0000-0000-000000000000', 'users', 'promote', 'Promote users to higher roles', 'User Management'),
|
||||
('a0010007-0000-0000-0000-000000000000', 'users', 'demote', 'Demote users to lower roles', 'User Management'),
|
||||
('a0010008-0000-0000-0000-000000000000', 'users', 'reset_password', 'Reset user passwords', 'User Management'),
|
||||
('a0010009-0000-0000-0000-000000000000', 'users', 'disable', 'Disable/disable user accounts', 'User Management'),
|
||||
('a0010010-0000-0000-0000-000000000000', 'users', 'permanently_delete', 'Permanently remove accounts from system', 'User Management')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- BAN & SUSPENSION (ADMIN + SUPER_ADMIN)
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0020001-0000-0000-0000-000000000000', 'bans', 'create', 'Ban a user account', 'Ban Management'),
|
||||
('a0020002-0000-0000-0000-000000000000', 'bans', 'reverse', 'Reverse a ban on a user', 'Ban Management'),
|
||||
('a0020003-0000-0000-0000-000000000000', 'bans', 'permanently_delete', 'Permanently delete banned users', 'Ban Management'),
|
||||
('a0020004-0000-0000-0000-000000000000', 'suspensions', 'create', 'Suspend a user account', 'Ban Management'),
|
||||
('a0020005-0000-0000-0000-000000000000', 'suspensions', 'lift', 'Lift a suspension', 'Ban Management'),
|
||||
('a0020006-0000-0000-0000-000000000000', 'suspensions', 'view', 'View suspension records', 'Ban Management')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- CRM CORE (SALES_USER + MANAGERS)
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0030001-0000-0000-0000-000000000000', 'customers', 'create', 'Create customer records', 'CRM'),
|
||||
('a0030002-0000-0000-0000-000000000000', 'customers', 'read', 'View customer records', 'CRM'),
|
||||
('a0030003-0000-0000-0000-000000000000', 'customers', 'update', 'Update customer records', 'CRM'),
|
||||
('a0030004-0000-0000-0000-000000000000', 'customers', 'delete', 'Delete customer records', 'CRM'),
|
||||
('a0030005-0000-0000-0000-000000000000', 'leads', 'create', 'Create sales leads', 'CRM'),
|
||||
('a0030006-0000-0000-0000-000000000000', 'leads', 'read', 'View sales leads', 'CRM'),
|
||||
('a0030007-0000-0000-0000-000000000000', 'leads', 'update', 'Update sales leads', 'CRM'),
|
||||
('a0030008-0000-0000-0000-000000000000', 'leads', 'convert', 'Convert leads to customers', 'CRM'),
|
||||
('a0030009-0000-0000-0000-000000000000', 'opportunities', 'create', 'Create sales opportunities', 'CRM'),
|
||||
('a0030010-0000-0000-0000-000000000000', 'opportunities', 'read', 'View sales opportunities', 'CRM'),
|
||||
('a0030011-0000-0000-0000-000000000000', 'opportunities', 'update', 'Update sales pipeline', 'CRM'),
|
||||
('a0030012-0000-0000-0000-000000000000', 'pipeline', 'update', 'Update sales pipeline stages', 'CRM'),
|
||||
('a0030013-0000-0000-0000-000000000000', 'communications', 'create', 'Log calls and meetings', 'CRM'),
|
||||
('a0030014-0000-0000-0000-000000000000', 'communications', 'read', 'View communication history', 'CRM'),
|
||||
('a0030015-0000-0000-0000-000000000000', 'contacts', 'manage', 'Manage customer contact details', 'CRM'),
|
||||
('a0030016-0000-0000-0000-000000000000', 'products', 'read', 'View product catalog', 'CRM'),
|
||||
('a0030017-0000-0000-0000-000000000000', 'invoices', 'read', 'View invoices', 'CRM')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- REPORTING & INCIDENTS (ADMIN)
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0040001-0000-0000-0000-000000000000', 'reports', 'customers_view', 'Review customer reports', 'Reporting'),
|
||||
('a0040002-0000-0000-0000-000000000000', 'reports', 'incidents_view', 'Review incident reports', 'Reporting'),
|
||||
('a0040003-0000-0000-0000-000000000000', 'reports', 'complaints_investigate', 'Investigate complaints', 'Reporting'),
|
||||
('a0040004-0000-0000-0000-000000000000', 'crm', 'dashboard_access', 'Access CRM dashboards', 'Reporting'),
|
||||
('a0040005-0000-0000-0000-000000000000', 'activity', 'logs_view', 'View user activity logs', 'Reporting'),
|
||||
('a0040006-0000-0000-0000-000000000000', 'suspicious', 'moderate', 'Moderate suspicious activity', 'Reporting'),
|
||||
('a0040007-0000-0000-0000-000000000000', 'accounts', 'lock_investigation', 'Lock accounts under investigation', 'Reporting')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- AUDIT & SYSTEM (SUPER_ADMIN + limited ADMIN)
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0050001-0000-0000-0000-000000000000', 'audit', 'full_access', 'Full audit log access', 'System'),
|
||||
('a0050002-0000-0000-0000-000000000000', 'audit', 'read', 'View audit logs', 'System'),
|
||||
('a0050003-0000-0000-0000-000000000000', 'settings', 'modify', 'Modify system settings', 'System'),
|
||||
('a0050004-0000-0000-0000-000000000000', 'database', 'full_access', 'Full database access', 'System'),
|
||||
('a0050005-0000-0000-0000-000000000000', 'crm', 'full_access', 'Full CRM access override', 'System'),
|
||||
('a0050006-0000-0000-0000-000000000000', 'permissions', 'override', 'Override all permissions', 'System')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- DEVELOPER PERMISSIONS
|
||||
INSERT INTO permissions (id, resource, action, description, category) VALUES
|
||||
('a0060001-0000-0000-0000-000000000000', 'api', 'testing', 'API testing access', 'Developer'),
|
||||
('a0060002-0000-0000-0000-000000000000', 'backend', 'diagnostics', 'Backend diagnostics', 'Developer'),
|
||||
('a0060003-0000-0000-0000-000000000000', 'system', 'logs_view', 'View system logs', 'Developer'),
|
||||
('a0060004-0000-0000-0000-000000000000', 'database', 'diagnostics', 'Database diagnostics', 'Developer'),
|
||||
('a0060005-0000-0000-0000-000000000000', 'debugging', 'access', 'Debugging access', 'Developer'),
|
||||
('a0060006-0000-0000-0000-000000000000', 'testing', 'internal', 'Internal testing access', 'Developer')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. ROLE-PERMISSION MAPPING
|
||||
-- ============================================================================
|
||||
|
||||
-- SUPER_ADMIN — ALL permissions
|
||||
INSERT INTO role_permissions (role_id, permission_id)
|
||||
SELECT '00000001-0000-0000-0000-000000000000', id FROM permissions
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ADMIN — Bans, Suspensions, Reporting, CRM read, Activity logs
|
||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||
-- Bans & Suspensions
|
||||
('00000002-0000-0000-0000-000000000000', 'a0020001-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0020004-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0020005-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0020006-0000-0000-0000-000000000000', NULL),
|
||||
-- User read + disable
|
||||
('00000002-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0010009-0000-0000-0000-000000000000', NULL),
|
||||
-- Reporting & Incidents
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040001-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040002-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040003-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040004-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040005-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040006-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0040007-0000-0000-0000-000000000000', NULL),
|
||||
-- CRM read access
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||
('00000002-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL),
|
||||
-- Audit read
|
||||
('00000002-0000-0000-0000-000000000000', 'a0050002-0000-0000-0000-000000000000', NULL)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- SALES_USER — CRM operations
|
||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030001-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030003-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030005-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030007-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030008-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030009-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030011-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030012-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030013-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030015-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||
('00000003-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- DEVELOPER — Technical permissions + CRM read access (post-sale project visibility)
|
||||
INSERT INTO role_permissions (role_id, permission_id, granted_by) VALUES
|
||||
-- Developer technical permissions
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060001-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060002-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060003-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060004-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060005-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0060006-0000-0000-0000-000000000000', NULL),
|
||||
-- User self-view
|
||||
('00000004-0000-0000-0000-000000000000', 'a0010002-0000-0000-0000-000000000000', NULL),
|
||||
-- CRM access (matching SALES_USER level for post-sale project visibility)
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030002-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030006-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030010-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030014-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030016-0000-0000-0000-000000000000', NULL),
|
||||
('00000004-0000-0000-0000-000000000000', 'a0030017-0000-0000-0000-000000000000', NULL)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. TEST ACCOUNTS
|
||||
-- ============================================================================
|
||||
-- Passwords (bcrypt, cost=12):
|
||||
-- superadmin_demo / SuperAdmin@2026
|
||||
-- admin_demo / AdminAccess@2026
|
||||
-- sales_demo / SalesAccess@2026
|
||||
-- dev_demo / DevTesting@2026
|
||||
|
||||
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
||||
'$2b$12$C5hczK17I.bu6ILzmGW0U.UnFSdfTuDh42C8t16nxRKaUtXKkdWlC',
|
||||
'Super', 'Admin', TRUE, FALSE),
|
||||
('00000000-0000-0000-0000-000000000002', 'admin_demo', 'admin@coastit.co.za',
|
||||
'$2b$12$TCDq5.sXHA4kWelQPKO6DeQo.WW.NeTuNtOed57UdQ3lRs7.rdkNy',
|
||||
'System', 'Admin', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000003', 'sales_demo', 'sales@coastit.co.za',
|
||||
'$2b$12$Xhh20UmTn.LTQAs4v4cHx.yQgvuYyNo6TkPaytQ1Q8o0oTPCtIj7W',
|
||||
'Sales', 'User', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||
'Dev', 'User', TRUE, TRUE)
|
||||
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||
|
||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
|
||||
|
||||
-- Set created_by for other users (SUPER_ADMIN created them)
|
||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
|
||||
AND created_by IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. ROLE ASSIGNMENTS
|
||||
-- ============================================================================
|
||||
|
||||
-- Assign SUPER_ADMIN role to superadmin_demo (bootstrap — assigned_by NULL)
|
||||
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||
('00000000-0000-0000-0000-000000000001', '00000001-0000-0000-0000-000000000000', NULL)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Assign remaining roles (SUPER_ADMIN assigned them)
|
||||
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. REFERENCE DATA
|
||||
-- ============================================================================
|
||||
|
||||
-- Customer Statuses
|
||||
INSERT INTO customer_statuses (id, name, description, color, sort_order, is_default) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', 'Active', 'Customer in good standing', '#22C55E', 1, TRUE),
|
||||
('c0000000-0000-0000-0000-000000000002', 'Inactive', 'No recent activity', '#A0AEC0', 2, FALSE),
|
||||
('c0000000-0000-0000-0000-000000000003', 'At Risk', 'Showing signs of churn', '#F59E0B', 3, FALSE),
|
||||
('c0000000-0000-0000-0000-000000000004', 'Churned', 'Relationship ended', '#EF4444', 4, FALSE),
|
||||
('c0000000-0000-0000-0000-000000000005', 'VIP', 'High-value priority customer', '#8B5CF6', 0, FALSE)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Lead Sources
|
||||
INSERT INTO lead_sources (id, name, description) VALUES
|
||||
('d0000000-0000-0000-0000-000000000001', 'Website', 'Organic website traffic or web forms'),
|
||||
('d0000000-0000-0000-0000-000000000002', 'Referral', 'Referred by existing customer'),
|
||||
('d0000000-0000-0000-0000-000000000003', 'LinkedIn', 'LinkedIn outreach'),
|
||||
('d0000000-0000-0000-0000-000000000004', 'Email Campaign', 'Email marketing campaign'),
|
||||
('d0000000-0000-0000-0000-000000000005', 'Trade Show', 'Event or trade show'),
|
||||
('d0000000-0000-0000-0000-000000000006', 'Cold Call', 'Outbound cold calling'),
|
||||
('d0000000-0000-0000-0000-000000000007', 'Partner', 'Channel partner referral'),
|
||||
('d0000000-0000-0000-0000-000000000008', 'Social Media', 'Social media platforms'),
|
||||
('d0000000-0000-0000-0000-000000000009', 'Advertisement', 'Paid advertising'),
|
||||
('d0000000-0000-0000-0000-000000000010', 'Other', 'Other sources')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Lead Stages
|
||||
INSERT INTO lead_stages (id, name, description, sort_order, probability) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'New', 'Newly captured lead', 1, 5),
|
||||
('e0000000-0000-0000-0000-000000000002', 'Contacted', 'Initial contact made', 2, 10),
|
||||
('e0000000-0000-0000-0000-000000000003', 'Qualified', 'Meets qualification criteria', 3, 25),
|
||||
('e0000000-0000-0000-0000-000000000004', 'Interested', 'Shown interest', 4, 40),
|
||||
('e0000000-0000-0000-0000-000000000005', 'Demo Scheduled', 'Product demo scheduled', 5, 60),
|
||||
('e0000000-0000-0000-0000-000000000006', 'Negotiation', 'In price/terms negotiation', 6, 75),
|
||||
('e0000000-0000-0000-0000-000000000007', 'Closed Won', 'Successfully converted', 7, 100),
|
||||
('e0000000-0000-0000-0000-000000000008', 'Closed Lost', 'Lead was lost', 8, 0)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Deal Stages
|
||||
INSERT INTO deal_stages (id, name, description, sort_order, probability) VALUES
|
||||
('f0000000-0000-0000-0000-000000000001', 'Prospecting', 'Initial qualification', 1, 10),
|
||||
('f0000000-0000-0000-0000-000000000002', 'Discovery', 'Understanding needs', 2, 20),
|
||||
('f0000000-0000-0000-0000-000000000003', 'Proposal', 'Solution proposed', 3, 40),
|
||||
('f0000000-0000-0000-0000-000000000004', 'Negotiation', 'Commercial discussions', 4, 60),
|
||||
('f0000000-0000-0000-0000-000000000005', 'Closing', 'Final stage before decision', 5, 80),
|
||||
('f0000000-0000-0000-0000-000000000006', 'Won', 'Deal closed won', 6, 100),
|
||||
('f0000000-0000-0000-0000-000000000007', 'Lost', 'Deal closed lost', 7, 0)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Communication Types
|
||||
INSERT INTO communication_types (id, name, description, icon) VALUES
|
||||
('b0000000-0000-0000-0000-000000000001', 'Email', 'Email correspondence', 'mail'),
|
||||
('b0000000-0000-0000-0000-000000000002', 'Phone Call', 'Telephone conversation', 'phone'),
|
||||
('b0000000-0000-0000-0000-000000000003', 'Meeting', 'In-person or virtual meeting', 'users'),
|
||||
('b0000000-0000-0000-0000-000000000004', 'Chat', 'Instant messaging', 'message-circle'),
|
||||
('b0000000-0000-0000-0000-000000000005', 'SMS', 'Text message', 'message-square'),
|
||||
('b0000000-0000-0000-0000-000000000006', 'Video Call', 'Video conference', 'video'),
|
||||
('b0000000-0000-0000-0000-000000000007', 'Letter', 'Physical mail', 'file-text')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Task Priorities
|
||||
INSERT INTO task_priorities (id, name, color, sort_order) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', 'Critical', '#EF4444', 1),
|
||||
('c0000000-0000-0000-0000-000000000002', 'High', '#F59E0B', 2),
|
||||
('c0000000-0000-0000-0000-000000000003', 'Medium', '#3B82F6', 3),
|
||||
('c0000000-0000-0000-0000-000000000004', 'Low', '#A0AEC0', 4)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Invoice Statuses
|
||||
INSERT INTO invoice_statuses (id, name, description, color, sort_order) VALUES
|
||||
('d0000000-0000-0000-0000-000000000001', 'Draft', 'Invoice in draft state', '#A0AEC0', 1),
|
||||
('d0000000-0000-0000-0000-000000000002', 'Sent', 'Invoice sent to customer', '#3B82F6', 2),
|
||||
('d0000000-0000-0000-0000-000000000003', 'Paid', 'Payment received in full', '#22C55E', 3),
|
||||
('d0000000-0000-0000-0000-000000000004', 'Overdue', 'Payment past due date', '#EF4444', 4),
|
||||
('d0000000-0000-0000-0000-000000000005', 'Partially Paid', 'Partial payment received', '#F59E0B', 5),
|
||||
('d0000000-0000-0000-0000-000000000006', 'Cancelled', 'Invoice cancelled', '#6B7280', 6),
|
||||
('d0000000-0000-0000-0000-000000000007', 'Refunded', 'Payment refunded', '#8B5CF6', 7)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Product Categories
|
||||
INSERT INTO product_categories (id, name, description, sort_order) VALUES
|
||||
('e0000000-0000-0000-0000-000000000001', 'Software', 'Software products and licenses', 1),
|
||||
('e0000000-0000-0000-0000-000000000002', 'Hardware', 'Physical hardware products', 2),
|
||||
('e0000000-0000-0000-0000-000000000003', 'Services', 'Professional services', 3),
|
||||
('e0000000-0000-0000-0000-000000000004', 'Subscription', 'Recurring subscription plans', 4),
|
||||
('e0000000-0000-0000-0000-000000000005', 'Training', 'Training and certification', 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. SAMPLE CRM DATA (for demo purposes)
|
||||
-- ============================================================================
|
||||
|
||||
-- Sample Products
|
||||
INSERT INTO products (id, category_id, name, description, sku, unit_price, cost_price) VALUES
|
||||
('f0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', 'CRM Pro License', 'Enterprise CRM license per user/year', 'SW-CRM-001', 1200.00, 300.00),
|
||||
('f0000000-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', 'CRM Basic License', 'Basic CRM license per user/year', 'SW-CRM-002', 600.00, 150.00),
|
||||
('f0000000-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000003', 'Implementation Service', 'CRM implementation and setup', 'SV-IMP-001', 15000.00, 7500.00),
|
||||
('f0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003', 'Consulting Day Rate', 'Senior consultant daily rate', 'SV-CON-001', 2500.00, 1000.00),
|
||||
('f0000000-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Basic', 'Basic user training per person', 'TR-BAS-001', 500.00, 200.00),
|
||||
('f0000000-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000005', 'CRM Training - Advanced', 'Advanced admin training per person', 'TR-ADV-001', 1000.00, 400.00)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample Customers
|
||||
INSERT INTO customers (id, customer_type, status_id, owner_id, source, tags, score) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'company', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', 'Website', ARRAY['tech','enterprise'], 85),
|
||||
('a0000000-0000-0000-0000-000000000002', 'individual', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', 'Referral', ARRAY['retail'], 60),
|
||||
('a0000000-0000-0000-0000-000000000003', 'company', 'c0000000-0000-0000-0000-000000000005',
|
||||
'00000000-0000-0000-0000-000000000003', 'LinkedIn', ARRAY['finance','enterprise','vip'], 95),
|
||||
('a0000000-0000-0000-0000-000000000004', 'individual', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000003', 'Email Campaign', ARRAY['education'], 15)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Company details
|
||||
INSERT INTO company_customers (customer_id, company_name, registration_number, tax_id, website, industry, company_size, annual_revenue) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'TechCorp International', 'REG-2024-001', 'TAX-987654', 'https://techcorp.example.com', 'Technology', '500-1000', 50000000.00),
|
||||
('a0000000-0000-0000-0000-000000000003', 'FinServ Group', 'REG-2024-002', 'TAX-123456', 'https://finserv.example.com', 'Financial Services', '1000-5000', 250000000.00)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Individual details
|
||||
INSERT INTO individual_customers (customer_id, first_name, last_name, job_title, company_name) VALUES
|
||||
('a0000000-0000-0000-0000-000000000002', 'Michael', 'Brown', 'Store Owner', 'Browns Retail Shop'),
|
||||
('a0000000-0000-0000-0000-000000000004', 'Sarah', 'Davis', 'Professor', 'State University')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Contact Information
|
||||
INSERT INTO contact_information (customer_id, type, value, is_primary) VALUES
|
||||
('a0000000-0000-0000-0000-000000000001', 'email', 'contact@techcorp.example.com', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000001', 'phone', '+1-555-0200', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000002', 'email', 'michael.brown@email.example.com', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000002', 'mobile', '+1-555-0201', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000003', 'email', 'info@finserv.example.com', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000003', 'phone', '+1-555-0202', TRUE),
|
||||
('a0000000-0000-0000-0000-000000000004', 'email', 'sarah.davis@email.example.com', TRUE)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample Leads
|
||||
INSERT INTO leads (id, source_id, stage_id, assigned_to, company_name, contact_name, email, interest_level, score) VALUES
|
||||
('c0010000-0000-0000-0000-000000000001', 'd0000000-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000004',
|
||||
'00000000-0000-0000-0000-000000000003', 'DataFlow Analytics', 'David Miller', 'david@dataflow.example.com', 'high', 70),
|
||||
('c0010000-0000-0000-0000-000000000002', 'd0000000-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000003', 'GreenEarth Nonprofit', 'Emma Green', 'emma@greenearth.example.com', 'medium', 45)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample Opportunities
|
||||
INSERT INTO opportunities (id, customer_id, stage_id, owner_id, name, estimated_revenue, probability, expected_close_date) VALUES
|
||||
('d0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'f0000000-0000-0000-0000-000000000005',
|
||||
'00000000-0000-0000-0000-000000000003', 'TechCorp - Annual Renewal', 120000.00, 80, '2024-12-31'),
|
||||
('d0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'f0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000003', 'FinServ - Enterprise Upgrade', 250000.00, 40, '2025-03-15')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Sample Tasks
|
||||
INSERT INTO tasks (id, customer_id, opportunity_id, assigned_to, assigned_by, title, priority_id, status, due_date) VALUES
|
||||
('e0010000-0000-0000-0000-000000000001', 'a0000000-0000-0000-0000-000000000001', 'd0010000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||
'Prepare renewal proposal for TechCorp', 'c0000000-0000-0000-0000-000000000001', 'in_progress', NOW() + INTERVAL '7 days'),
|
||||
('e0010000-0000-0000-0000-000000000002', 'a0000000-0000-0000-0000-000000000003', 'd0010000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001',
|
||||
'Schedule FinServ executive meeting', 'c0000000-0000-0000-0000-000000000002', 'pending', NOW() + INTERVAL '3 days')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -1,82 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- Chat System: conversations, participants, and messages
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversation_participants (
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_id ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_id ON conversation_participants(user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_created ON messages(conversation_id, created_at DESC);
|
||||
|
||||
-- Seed conversations between superadmin and other users
|
||||
INSERT INTO conversations (id, created_at) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', NOW() - INTERVAL '2 hours'),
|
||||
('c0000000-0000-0000-0000-000000000002', NOW() - INTERVAL '1 hour'),
|
||||
('c0000000-0000-0000-0000-000000000003', NOW() - INTERVAL '30 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Add participants (superadmin + each sales/dev user)
|
||||
INSERT INTO conversation_participants (conversation_id, user_id) VALUES
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000004'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000001'),
|
||||
('c0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000002')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed some messages
|
||||
INSERT INTO messages (id, conversation_id, sender_id, content, created_at) VALUES
|
||||
('00000000-0000-0000-0000-000000000101', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', 'Hey! Just finalized the TechCorp deal.',
|
||||
NOW() - INTERVAL '2 hours'),
|
||||
('00000000-0000-0000-0000-000000000102', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great work! What were the final terms?',
|
||||
NOW() - INTERVAL '105 minutes'),
|
||||
('00000000-0000-0000-0000-000000000103', 'c0000000-0000-0000-0000-000000000001',
|
||||
'00000000-0000-0000-0000-000000000003', '3-year enterprise license, 15% discount. They signed this morning.',
|
||||
NOW() - INTERVAL '100 minutes'),
|
||||
('00000000-0000-0000-0000-000000000104', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'The API integration tests are passing on staging.',
|
||||
NOW() - INTERVAL '50 minutes'),
|
||||
('00000000-0000-0000-0000-000000000105', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000001', 'Great, when can we deploy to production?',
|
||||
NOW() - INTERVAL '45 minutes'),
|
||||
('00000000-0000-0000-0000-000000000106', 'c0000000-0000-0000-0000-000000000002',
|
||||
'00000000-0000-0000-0000-000000000004', 'Tomorrow morning after final review.',
|
||||
NOW() - INTERVAL '40 minutes'),
|
||||
('00000000-0000-0000-0000-000000000107', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000002', 'New report shows 23% increase in lead conversion this quarter.',
|
||||
NOW() - INTERVAL '25 minutes'),
|
||||
('00000000-0000-0000-0000-000000000108', 'c0000000-0000-0000-0000-000000000003',
|
||||
'00000000-0000-0000-0000-000000000001', 'That is excellent! Let us discuss in the next team meeting.',
|
||||
NOW() - INTERVAL '20 minutes')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Update conversation timestamps
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '20 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000003';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '40 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000002';
|
||||
UPDATE conversations SET updated_at = NOW() - INTERVAL '100 minutes' WHERE id = 'c0000000-0000-0000-0000-000000000001';
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS last_read_at TIMESTAMPTZ;
|
||||
UPDATE conversation_participants SET last_read_at = NOW() WHERE last_read_at IS NULL;
|
||||
@@ -1,87 +0,0 @@
|
||||
-- Test leads - 2026-06-18
|
||||
INSERT INTO leads (id, stage_id, assigned_to, company_name, contact_name, email, phone, created_at, updated_at) VALUES
|
||||
('c0011001-0000-0000-0000-000000000001', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 1', 'Contact 1', 'contact1@test.com', '555-2636', '2025-12-23T22:09:54Z', '2025-12-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000002', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 2', 'Contact 2', 'contact2@test.com', '555-5691', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000003', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 3', 'Contact 3', 'contact3@test.com', '555-6446', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000004', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 4', 'Contact 4', 'contact4@test.com', '555-5969', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000005', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 5', 'Contact 5', 'contact5@test.com', '555-4200', '2026-01-14T22:09:54Z', '2026-01-14T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000006', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 6', 'Contact 6', 'contact6@test.com', '555-8324', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000007', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 7', 'Contact 7', 'contact7@test.com', '555-1047', '2025-12-27T22:09:54Z', '2025-12-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000008', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 8', 'Contact 8', 'contact8@test.com', '555-634', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000009', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 9', 'Contact 9', 'contact9@test.com', '555-3245', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000010', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 10', 'Contact 10', 'contact10@test.com', '555-3656', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000011', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 11', 'Contact 11', 'contact11@test.com', '555-2993', '2026-01-23T22:09:54Z', '2026-01-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000012', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 12', 'Contact 12', 'contact12@test.com', '555-1634', '2026-02-24T22:09:54Z', '2026-02-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000013', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 13', 'Contact 13', 'contact13@test.com', '555-2032', '2026-02-10T22:09:54Z', '2026-02-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000014', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 14', 'Contact 14', 'contact14@test.com', '555-867', '2026-01-31T22:09:54Z', '2026-01-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000015', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 15', 'Contact 15', 'contact15@test.com', '555-2136', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000016', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 16', 'Contact 16', 'contact16@test.com', '555-89', '2026-01-20T22:09:54Z', '2026-01-20T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000017', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 17', 'Contact 17', 'contact17@test.com', '555-4274', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000018', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 18', 'Contact 18', 'contact18@test.com', '555-4294', '2026-03-16T22:09:54Z', '2026-03-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000019', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 19', 'Contact 19', 'contact19@test.com', '555-7998', '2025-12-30T22:09:54Z', '2025-12-30T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000020', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 20', 'Contact 20', 'contact20@test.com', '555-8557', '2026-02-13T22:09:54Z', '2026-02-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000021', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 21', 'Contact 21', 'contact21@test.com', '555-5170', '2026-04-11T22:09:54Z', '2026-04-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000022', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 22', 'Contact 22', 'contact22@test.com', '555-6250', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000023', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 23', 'Contact 23', 'contact23@test.com', '555-5611', '2026-01-06T22:09:54Z', '2026-01-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000024', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 24', 'Contact 24', 'contact24@test.com', '555-3467', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000025', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 25', 'Contact 25', 'contact25@test.com', '555-7087', '2026-05-07T22:09:54Z', '2026-05-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000026', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 26', 'Contact 26', 'contact26@test.com', '555-2945', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000027', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 27', 'Contact 27', 'contact27@test.com', '555-9502', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000028', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 28', 'Contact 28', 'contact28@test.com', '555-8114', '2026-05-10T22:09:54Z', '2026-05-10T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000029', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 29', 'Contact 29', 'contact29@test.com', '555-4926', '2026-03-02T22:09:54Z', '2026-03-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000030', 'e0000000-0000-0000-0000-000000000001', '00000000-0000-0000-0000-000000000003', 'Test Company 30', 'Contact 30', 'contact30@test.com', '555-2421', '2026-03-17T22:09:54Z', '2026-03-17T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000031', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 31', 'Contact 31', 'contact31@test.com', '555-7506', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000032', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 32', 'Contact 32', 'contact32@test.com', '555-6832', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000033', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 33', 'Contact 33', 'contact33@test.com', '555-6548', '2026-01-07T22:09:54Z', '2026-01-07T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000034', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 34', 'Contact 34', 'contact34@test.com', '555-8793', '2026-01-02T22:09:54Z', '2026-01-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000035', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 35', 'Contact 35', 'contact35@test.com', '555-6217', '2026-01-29T22:09:54Z', '2026-01-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000036', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 36', 'Contact 36', 'contact36@test.com', '555-9206', '2025-12-31T22:09:54Z', '2025-12-31T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000037', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 37', 'Contact 37', 'contact37@test.com', '555-1599', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000038', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 38', 'Contact 38', 'contact38@test.com', '555-3873', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000039', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 39', 'Contact 39', 'contact39@test.com', '555-6122', '2026-01-09T22:09:54Z', '2026-01-09T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000040', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 40', 'Contact 40', 'contact40@test.com', '555-832', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000041', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 41', 'Contact 41', 'contact41@test.com', '555-5979', '2026-01-11T22:09:54Z', '2026-01-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000042', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 42', 'Contact 42', 'contact42@test.com', '555-1823', '2025-12-26T22:09:54Z', '2025-12-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000043', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 43', 'Contact 43', 'contact43@test.com', '555-7102', '2026-02-28T22:09:54Z', '2026-02-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000044', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 44', 'Contact 44', 'contact44@test.com', '555-5106', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000045', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 45', 'Contact 45', 'contact45@test.com', '555-3278', '2026-03-27T22:09:54Z', '2026-03-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000046', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 46', 'Contact 46', 'contact46@test.com', '555-4973', '2026-04-28T22:09:54Z', '2026-04-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000047', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 47', 'Contact 47', 'contact47@test.com', '555-3297', '2026-03-01T22:09:54Z', '2026-03-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000048', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 48', 'Contact 48', 'contact48@test.com', '555-4584', '2026-03-18T22:09:54Z', '2026-03-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000049', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 49', 'Contact 49', 'contact49@test.com', '555-6788', '2026-03-04T22:09:54Z', '2026-03-04T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000050', 'e0000000-0000-0000-0000-000000000002', '00000000-0000-0000-0000-000000000003', 'Test Company 50', 'Contact 50', 'contact50@test.com', '555-2734', '2026-01-03T22:09:54Z', '2026-01-03T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000051', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 51', 'Contact 51', 'contact51@test.com', '555-2625', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000052', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 52', 'Contact 52', 'contact52@test.com', '555-620', '2025-12-21T22:09:54Z', '2025-12-21T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000053', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 53', 'Contact 53', 'contact53@test.com', '555-6691', '2025-12-22T22:09:54Z', '2025-12-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000054', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 54', 'Contact 54', 'contact54@test.com', '555-8847', '2026-01-25T22:09:54Z', '2026-01-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000055', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 55', 'Contact 55', 'contact55@test.com', '555-2187', '2026-02-02T22:09:54Z', '2026-02-02T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000056', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 56', 'Contact 56', 'contact56@test.com', '555-3267', '2026-01-19T22:09:54Z', '2026-01-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000057', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 57', 'Contact 57', 'contact57@test.com', '555-8119', '2026-03-05T22:09:54Z', '2026-03-05T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000058', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 58', 'Contact 58', 'contact58@test.com', '555-3181', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000059', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 59', 'Contact 59', 'contact59@test.com', '555-4403', '2026-02-23T22:09:54Z', '2026-02-23T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000060', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 60', 'Contact 60', 'contact60@test.com', '555-1407', '2026-04-06T22:09:54Z', '2026-04-06T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000061', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 61', 'Contact 61', 'contact61@test.com', '555-2333', '2026-02-27T22:09:54Z', '2026-02-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000062', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 62', 'Contact 62', 'contact62@test.com', '555-1436', '2026-04-26T22:09:54Z', '2026-04-26T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000063', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 63', 'Contact 63', 'contact63@test.com', '555-7135', '2026-04-19T22:09:54Z', '2026-04-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000064', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 64', 'Contact 64', 'contact64@test.com', '555-9366', '2026-01-22T22:09:54Z', '2026-01-22T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000065', 'e0000000-0000-0000-0000-000000000003', '00000000-0000-0000-0000-000000000003', 'Test Company 65', 'Contact 65', 'contact65@test.com', '555-7135', '2026-02-18T22:09:54Z', '2026-02-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000066', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 66', 'Contact 66', 'contact66@test.com', '555-6854', '2025-12-19T22:09:54Z', '2025-12-19T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000067', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 67', 'Contact 67', 'contact67@test.com', '555-2322', '2025-12-24T22:09:54Z', '2025-12-24T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000068', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 68', 'Contact 68', 'contact68@test.com', '555-2632', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000069', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 69', 'Contact 69', 'contact69@test.com', '555-876', '2025-12-29T22:09:54Z', '2025-12-29T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000070', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 70', 'Contact 70', 'contact70@test.com', '555-974', '2026-01-15T22:09:54Z', '2026-01-15T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000071', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 71', 'Contact 71', 'contact71@test.com', '555-1919', '2026-01-18T22:09:54Z', '2026-01-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000072', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 72', 'Contact 72', 'contact72@test.com', '555-3034', '2025-12-25T22:09:54Z', '2025-12-25T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000073', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 73', 'Contact 73', 'contact73@test.com', '555-1795', '2026-01-27T22:09:54Z', '2026-01-27T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000074', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 74', 'Contact 74', 'contact74@test.com', '555-727', '2026-01-08T22:09:54Z', '2026-01-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000075', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 75', 'Contact 75', 'contact75@test.com', '555-3877', '2026-03-08T22:09:54Z', '2026-03-08T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000076', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 76', 'Contact 76', 'contact76@test.com', '555-6384', '2026-01-13T22:09:54Z', '2026-01-13T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000077', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 77', 'Contact 77', 'contact77@test.com', '555-5607', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000078', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 78', 'Contact 78', 'contact78@test.com', '555-6225', '2026-01-12T22:09:54Z', '2026-01-12T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000079', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 79', 'Contact 79', 'contact79@test.com', '555-9288', '2026-04-16T22:09:54Z', '2026-04-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000080', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 80', 'Contact 80', 'contact80@test.com', '555-8660', '2026-02-16T22:09:54Z', '2026-02-16T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000081', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 81', 'Contact 81', 'contact81@test.com', '555-826', '2025-12-28T22:09:54Z', '2025-12-28T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000082', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 82', 'Contact 82', 'contact82@test.com', '555-1761', '2026-03-11T22:09:54Z', '2026-03-11T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000083', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 83', 'Contact 83', 'contact83@test.com', '555-9860', '2026-05-18T22:09:54Z', '2026-05-18T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000084', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 84', 'Contact 84', 'contact84@test.com', '555-1104', '2026-06-01T22:09:54Z', '2026-06-01T22:09:54Z'),
|
||||
('c0011001-0000-0000-0000-000000000085', 'e0000000-0000-0000-0000-000000000007', '00000000-0000-0000-0000-000000000003', 'Test Company 85', 'Contact 85', 'contact85@test.com', '555-9528', '2026-06-14T22:09:54Z', '2026-06-14T22:09:54Z');
|
||||
@@ -1,13 +0,0 @@
|
||||
-- AI Sales Assistant tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'sales',
|
||||
message TEXT NOT NULL,
|
||||
response TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user ON ai_conversations(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_created ON ai_conversations(created_at DESC);
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
link TEXT,
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, created_at DESC) WHERE is_read = FALSE;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
lead_assigned BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
lead_status BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
note_added BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
daily_digest BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
weekly_report BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS company_settings (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
company_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_phone VARCHAR(50) NOT NULL DEFAULT '',
|
||||
company_website VARCHAR(255) NOT NULL DEFAULT '',
|
||||
company_address TEXT NOT NULL DEFAULT '',
|
||||
updated_by UUID REFERENCES users(id),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address)
|
||||
VALUES ('Coastal IT Solutions', 'info@coastalit.com', '(555) 123-4567', 'https://coastalit.com', '123 Business Ave, Suite 100, San Francisco, CA 94105')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
timezone VARCHAR(100) NOT NULL DEFAULT 'america-los_angeles',
|
||||
date_format VARCHAR(10) NOT NULL DEFAULT 'mdy',
|
||||
items_per_page INTEGER NOT NULL DEFAULT 20,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -1,4 +0,0 @@
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_id UUID;
|
||||
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id) WHERE context_type IS NOT NULL;
|
||||
@@ -1,34 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS facebook_accounts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
label VARCHAR(100) NOT NULL,
|
||||
profile_path TEXT NOT NULL,
|
||||
cookie_file TEXT NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
last_scrape_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error_at TIMESTAMPTZ,
|
||||
last_error_message TEXT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
flagged BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
flagged_at TIMESTAMPTZ,
|
||||
flagged_reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_active ON facebook_accounts(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_accounts_flagged ON facebook_accounts(flagged);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS facebook_scrape_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
account_id UUID NOT NULL REFERENCES facebook_accounts(id) ON DELETE CASCADE,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
completed_at TIMESTAMPTZ,
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
leads_found INT NOT NULL DEFAULT 0,
|
||||
error_message TEXT,
|
||||
detected_flag VARCHAR(50),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account ON facebook_scrape_logs(account_id, created_at DESC);
|
||||
@@ -1,38 +0,0 @@
|
||||
-- ============================================================================
|
||||
-- CRM Database — Full Migration Runner
|
||||
-- ============================================================================
|
||||
-- Usage: psql -U postgres -d crm -f run_all.sql
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
\set ON_ERROR_STOP on
|
||||
|
||||
\echo '=== Running 001_schema.sql (Tables + Constraints + Functions + Views) ==='
|
||||
\i 001_schema.sql
|
||||
|
||||
\echo '=== Running 002_seed.sql (RBAC + Test Accounts + Reference Data) ==='
|
||||
\i 002_seed.sql
|
||||
|
||||
\echo '=== Running 003_chat.sql (Chat Tables) ==='
|
||||
\i 003_chat.sql
|
||||
|
||||
\echo '=== Running 004_avatar_url.sql (Avatar URL Column) ==='
|
||||
\i 004_avatar_url.sql
|
||||
|
||||
\echo '=== Running 005_last_read_at.sql (Last Read At Column) ==='
|
||||
\i 005_last_read_at.sql
|
||||
|
||||
\echo '=== Running 006_test_leads.sql (Test Lead Data) ==='
|
||||
\i 006_test_leads.sql
|
||||
|
||||
\echo '=== Running 007_ai_features.sql (AI Features) ==='
|
||||
\i 007_ai_features.sql
|
||||
|
||||
\echo '=== Running 008_notifications.sql (Notifications + Preferences) ==='
|
||||
\i 008_notifications.sql
|
||||
|
||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||
\i 009_settings.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
COMMIT;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -1,71 +0,0 @@
|
||||
# Scrapers - Configuration & Status
|
||||
|
||||
## Facebook Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~70-85
|
||||
|
||||
Target URL (line 72):
|
||||
```rust
|
||||
let url = "https://www.facebook.com/search/top/?q=need%20website%20create";
|
||||
```
|
||||
|
||||
**Status:** Uses direct connection (no proxy) — your home IP. Facebook blocks datacenter IPs. May work from a residential connection.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://www.facebook.com/search/top/?q=need%20website%20create" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" --max-time 10 2>$null
|
||||
```
|
||||
|
||||
**Expected log output when blocked:**
|
||||
```
|
||||
ERROR crm_ai: Facebook scraper error: error sending request for url (https://www.facebook.com/search/top/?q=need%20website%20create)
|
||||
```
|
||||
|
||||
## Reddit Scraper
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~90-120
|
||||
|
||||
Uses `old.reddit.com` (older design that allows scraping without captchas).
|
||||
|
||||
Search queries (currently 6, lines 93-100):
|
||||
- `r/southafrica` — "need website", "web developer"
|
||||
- Global — '"need a website"', "website quote"
|
||||
- `r/forhire` — "website"
|
||||
- `r/smallbusiness` — "website"
|
||||
|
||||
**Status:** Working. Reddit results appear as `INFO LEAD:` entries in the server log.
|
||||
|
||||
**Test with curl:**
|
||||
```
|
||||
curl.exe -s "https://old.reddit.com/r/southafrica/search?q=need+website&sort=new&restrict_sr=on&t=week" -H "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
```
|
||||
|
||||
**Expected log output when working:**
|
||||
```
|
||||
INFO crm_ai: LEAD: [Post Title] -> https://old.reddit.com/r/.../...
|
||||
```
|
||||
|
||||
## Background Loop
|
||||
|
||||
**File:** `rust-ai/src/main.rs`
|
||||
**Lines:** ~370-383
|
||||
|
||||
Both scrapers run together every 60-180 seconds on a `spawn_blocking` thread.
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Facebook
|
||||
- Try running from your home PC instead — your residential IP may not be blocked
|
||||
- If still blocked, you need residential proxies (BrightData, IPRoyal, Oxylabs)
|
||||
- Configure them in the `PROXIES` array at line 25
|
||||
|
||||
### Reddit
|
||||
- Works out of the box via `old.reddit.com`
|
||||
- If it stops working, Reddit IP-blocked you — use proxies or switch to Playwright
|
||||
|
||||
### To add more search queries
|
||||
Edit the `searches` array in `run_reddit_scraper()` (line 93).
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { NextConfig } from "next"
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: false,
|
||||
},
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "ui-avatars.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
Generated
-7999
File diff suppressed because it is too large
Load Diff
@@ -1,67 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||
"dev:start": "concurrently -n AI,BROWSE,NEXT -c cyan,magenta,green \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:next\"",
|
||||
"dev:next": "next dev -p 3006",
|
||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "cd browser-use-service && python main.py",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3006",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.3",
|
||||
"@radix-ui/react-select": "^2.1.6",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jose": "^6.2.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^15.0.7",
|
||||
"next-themes": "^0.4.4",
|
||||
"pg": "^8.21.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"concurrently": "^10.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
export default config
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 24 KiB |
@@ -1,2 +0,0 @@
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
linker = "C:\\Users\\Hannah Kaur Bagga\\.rustup\\toolchains\\stable-x86_64-pc-windows-gnu\\lib\\rustlib\\x86_64-pc-windows-gnu\\bin\\rust-lld.exe"
|
||||
-2687
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "crm-ai"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "AI Sales Assistant backend for Coast IT CRM"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "process"] }
|
||||
reqwest = { version = "0.12", features = ["json", "blocking"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sqlx = { version = "0.9", features = ["runtime-tokio", "postgres", "chrono", "uuid"] }
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
dotenvy = "0.15"
|
||||
rand = "0.8"
|
||||
jsonwebtoken = "9"
|
||||
@@ -1,43 +0,0 @@
|
||||
# CRM AI Service — Self-Knowledge
|
||||
|
||||
## Identity
|
||||
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
||||
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
||||
Your purpose is to help salespeople close more deals.
|
||||
|
||||
## Capabilities
|
||||
- Give sales tips and strategies per job category
|
||||
- Generate cold email and outreach templates
|
||||
- Handle objections with proven rebuttals
|
||||
- Analyse prospect behaviour and suggest next steps
|
||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||
|
||||
## Architecture
|
||||
```
|
||||
User → Next.js → Rust (:3001) → Ollama (:11434)
|
||||
↓
|
||||
PostgreSQL
|
||||
```
|
||||
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
|
||||
|
||||
## Facebook Scraper (in code but not yet active)
|
||||
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
|
||||
To activate: call `run_facebook_scraper()` from the main loop.
|
||||
Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||
|
||||
## Self-Improvement Protocol
|
||||
1. You notice a gap in your knowledge or a pattern in user questions
|
||||
2. You call `POST /ai/instructions` with:
|
||||
- `entry`: description of the improvement
|
||||
- `content`: optional full replacement of ai.md
|
||||
3. The improvement is logged and loaded into the next system prompt
|
||||
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
|
||||
|
||||
## Response Rules
|
||||
- Be direct and actionable — no fluff, no AI disclaimers
|
||||
- Use short paragraphs and bullet points
|
||||
- Never mention being an AI or language model
|
||||
- If you don't know something, say so honestly
|
||||
- Prioritise the user's role: salespeople need speed, admins need control
|
||||
@@ -1,670 +0,0 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use tower_http::cors::{CorsLayer, AllowOrigin, Any};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation, Algorithm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
use rand::Rng;
|
||||
use chrono::Timelike;
|
||||
use std::time::Duration;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
// ── JWT Claims ────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Claims {
|
||||
#[serde(rename = "userId")]
|
||||
user_id: String,
|
||||
role: String,
|
||||
}
|
||||
|
||||
fn verify_jwt(token: &str, secret: &str) -> Option<Claims> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let validation = Validation::new(Algorithm::HS256);
|
||||
decode::<Claims>(token, &key, &validation).ok().map(|d| d.claims)
|
||||
}
|
||||
|
||||
// ── Rate limiter ──────────────────────────────────────────────
|
||||
|
||||
struct RateLimiter {
|
||||
buckets: Mutex<HashMap<String, Vec<u64>>>,
|
||||
max_requests: usize,
|
||||
window_secs: u64,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
fn new(max_requests: usize, window_secs: u64) -> Self {
|
||||
Self {
|
||||
buckets: Mutex::new(HashMap::new()),
|
||||
max_requests,
|
||||
window_secs,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check(&self, key: &str) -> bool {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let mut buckets = self.buckets.lock().await;
|
||||
let timestamps = buckets.entry(key.to_string()).or_default();
|
||||
timestamps.retain(|t| now - *t <= self.window_secs);
|
||||
if timestamps.len() >= self.max_requests {
|
||||
false
|
||||
} else {
|
||||
timestamps.push(now);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared state ───────────────────────────────────────────────
|
||||
|
||||
struct AppState {
|
||||
db: sqlx::PgPool,
|
||||
ollama_url: String,
|
||||
model: String,
|
||||
jobs: Vec<Job>,
|
||||
leads: Arc<Mutex<LeadStore>>,
|
||||
http_client: reqwest::Client,
|
||||
jwt_secret: String,
|
||||
rate_limiter: RateLimiter,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct Lead {
|
||||
title: String,
|
||||
url: String,
|
||||
source: String,
|
||||
found_at: u64,
|
||||
author: String,
|
||||
date: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct LeadStore {
|
||||
leads: Vec<Lead>,
|
||||
max_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ScrapeResponse {
|
||||
success: bool,
|
||||
leads: Vec<ScrapeLead>,
|
||||
flagged: bool,
|
||||
flag_reason: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct ScrapeLead {
|
||||
title: String,
|
||||
url: String,
|
||||
author: String,
|
||||
date: String,
|
||||
content: String,
|
||||
source: Option<String>,
|
||||
}
|
||||
|
||||
impl LeadStore {
|
||||
fn new(max_size: usize) -> Self {
|
||||
Self { leads: Vec::new(), max_size }
|
||||
}
|
||||
|
||||
fn push(&mut self, lead: Lead) {
|
||||
if !self.leads.iter().any(|l| l.url == lead.url) {
|
||||
self.leads.insert(0, lead);
|
||||
self.leads.truncate(self.max_size);
|
||||
}
|
||||
}
|
||||
|
||||
fn recent(&self, max_age_secs: u64, limit: usize) -> Vec<Lead> {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
self.leads.iter()
|
||||
.filter(|l| now.saturating_sub(l.found_at) <= max_age_secs)
|
||||
.take(limit)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Job {
|
||||
job_title: String,
|
||||
keywords: Vec<String>,
|
||||
industry: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ChatRequest {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ChatResponse {
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct JobsResponse {
|
||||
jobs: Vec<Job>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
model: String,
|
||||
}
|
||||
|
||||
// ── Ollama API types ───────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaRequest {
|
||||
model: String,
|
||||
messages: Vec<OllamaChatMessage>,
|
||||
stream: bool,
|
||||
options: OllamaOptions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct OllamaOptions {
|
||||
temperature: f32,
|
||||
num_predict: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponse {
|
||||
message: Option<OllamaResponseMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OllamaResponseMessage {
|
||||
content: String,
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
s.chars().take(max).collect()
|
||||
}
|
||||
|
||||
fn extract_claims(headers: &HeaderMap, state: &AppState) -> Result<Claims, (StatusCode, String)> {
|
||||
let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()).unwrap_or("");
|
||||
let token = auth_header.strip_prefix("Bearer ").unwrap_or("");
|
||||
let claims = verify_jwt(token, &state.jwt_secret).ok_or_else(|| {
|
||||
(StatusCode::UNAUTHORIZED, "Unauthorized".to_string())
|
||||
})?;
|
||||
match claims.role.to_lowercase().as_str() {
|
||||
"sales" | "admin" | "super_admin" => Ok(claims),
|
||||
_ => Err((StatusCode::FORBIDDEN, "Forbidden".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_leads_output(leads: &[Lead]) -> String {
|
||||
if leads.is_empty() {
|
||||
return "No new requests found yet.".to_string();
|
||||
}
|
||||
leads
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let author = if l.author.is_empty() { "Unknown" } else { &l.author };
|
||||
let date = truncate(&l.date, 10);
|
||||
format!(
|
||||
"{}. {}\n {}\n {}\n {}",
|
||||
i + 1,
|
||||
author,
|
||||
date,
|
||||
l.title,
|
||||
l.url
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn build_system_prompt(jobs: &[Job], leads: &[Lead]) -> String {
|
||||
let job_list: Vec<String> = jobs
|
||||
.iter()
|
||||
.map(|j| format!("- {} ({}): {}", j.job_title, j.industry, j.description))
|
||||
.collect();
|
||||
let job_list_str = job_list.join("\n");
|
||||
|
||||
let lead_summary: Vec<String> = leads
|
||||
.iter()
|
||||
.map(|l| format!("{} | {} | {}", l.author, l.title, l.url))
|
||||
.collect();
|
||||
let lead_summary_str = if lead_summary.is_empty() {
|
||||
"None yet.".to_string()
|
||||
} else {
|
||||
lead_summary.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
"You are a Sales AI Assistant for Coast IT CRM.\n\n\
|
||||
Available job categories to target:\n{}\n\n\
|
||||
Recent leads context:\n{}\n\n\
|
||||
Rules:\n\
|
||||
- When asked about leads, answer concisely under 150 words.\n\
|
||||
- If asked to suggest a sales strategy, give brief actionable advice.\n\
|
||||
- Be direct and professional. No fluff.",
|
||||
job_list_str, lead_summary_str
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chat handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_chat(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
Json(req): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, (StatusCode, String)> {
|
||||
let claims = extract_claims(&headers, &state)?;
|
||||
|
||||
if !state.rate_limiter.check(&claims.user_id).await {
|
||||
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||
}
|
||||
|
||||
let msg_lower = req.message.to_lowercase();
|
||||
let msg_words: Vec<&str> = msg_lower.split_whitespace().collect();
|
||||
let has_listing = msg_words.iter().any(|w| ["listings", "listing", "leads", "links", "lists"].contains(w));
|
||||
let has_show = msg_lower.contains("show me") || msg_lower.contains("give me") || msg_lower.contains("pull");
|
||||
let has_job = msg_words.contains(&"jobs") || msg_words.contains(&"job");
|
||||
if has_listing || (has_show && has_job) || (has_show && msg_lower.contains("links")) || msg_lower.contains("recent leads") {
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
let base_url = "http://localhost:3008/scrape/facebook";
|
||||
use std::fmt::Write;
|
||||
let mut service_url = base_url.to_string();
|
||||
if let Ok(Some((_, path))) = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||
"SELECT id, profile_path FROM facebook_accounts \
|
||||
WHERE is_active = TRUE AND flagged = FALSE \
|
||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
{
|
||||
let encoded: String = path.chars().map(|c| match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '-' | '_' | '~' => c.to_string(),
|
||||
_ => format!("%{:02X}", c as u8),
|
||||
}).collect();
|
||||
write!(service_url, "?profile_path={}&force=true", encoded).unwrap();
|
||||
info!("Calling Python scrape at: {}?profile_path=...&force=true", base_url);
|
||||
} else {
|
||||
warn!("No active Facebook account found for on-demand scrape");
|
||||
}
|
||||
let req_builder = state.http_client.post(&service_url);
|
||||
|
||||
match req_builder.send().await {
|
||||
Ok(resp) => {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
info!("Python scrape response ({}): {} bytes", status, body.len());
|
||||
if body.starts_with('{') {
|
||||
match serde_json::from_str::<ScrapeResponse>(&body) {
|
||||
Ok(scrape_resp) => {
|
||||
info!("Scraped {} leads from Facebook", scrape_resp.leads.len());
|
||||
if scrape_resp.leads.is_empty() && scrape_resp.error.is_some() {
|
||||
warn!("Python returned error: {:?}", scrape_resp.error);
|
||||
}
|
||||
let mut store = state.leads.lock().await;
|
||||
for item in &scrape_resp.leads {
|
||||
store.push(Lead {
|
||||
title: truncate(&item.title, 120),
|
||||
url: item.url.clone(),
|
||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||
found_at: now,
|
||||
author: truncate(&item.author, 60),
|
||||
date: truncate(&item.date, 30),
|
||||
content: truncate(&item.content, 300),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse Python scrape response: {} body: {}", e, &body[..body.len().min(200)]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Python returned non-JSON response: {}", &body[..body.len().min(200)]);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Scraper request error: {} - URL: {}", e, base_url);
|
||||
}
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||
let response = format_leads_output(&recent_leads);
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||
.bind(&claims.role)
|
||||
.bind(&req.message)
|
||||
.bind(&response)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
return Ok(Json(ChatResponse { response }));
|
||||
}
|
||||
|
||||
let recent_leads = state.leads.lock().await.recent(604800, 20);
|
||||
let system_prompt = build_system_prompt(&state.jobs, &recent_leads);
|
||||
let message_text = req.message.clone();
|
||||
|
||||
let ollama_req = OllamaRequest {
|
||||
model: state.model.clone(),
|
||||
messages: vec![
|
||||
OllamaChatMessage { role: "system".to_string(), content: system_prompt },
|
||||
OllamaChatMessage { role: "user".to_string(), content: req.message.clone() },
|
||||
],
|
||||
stream: false,
|
||||
options: OllamaOptions { temperature: 0.7, num_predict: 1024 },
|
||||
};
|
||||
|
||||
let resp = state.http_client
|
||||
.post(format!("{}/api/chat", state.ollama_url))
|
||||
.json(&ollama_req)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Ollama request failed: {}", e);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "AI service unavailable".to_string())
|
||||
})?;
|
||||
|
||||
let ollama_resp: OllamaResponse = resp.json().await.map_err(|e| {
|
||||
error!("Failed to parse Ollama response: {}", e);
|
||||
(StatusCode::SERVICE_UNAVAILABLE, "AI response parse error".to_string())
|
||||
})?;
|
||||
|
||||
let response_text = ollama_resp.message.map(|m| m.content).unwrap_or_default();
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO ai_conversations (id, user_id, role, message, response) VALUES ($1, $2, $3, $4, $5)",
|
||||
)
|
||||
.bind(Uuid::new_v4())
|
||||
.bind(Uuid::parse_str(&claims.user_id).unwrap_or(Uuid::nil()))
|
||||
.bind(&claims.role)
|
||||
.bind(&message_text)
|
||||
.bind(&response_text)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
|
||||
Ok(Json(ChatResponse { response: response_text }))
|
||||
}
|
||||
|
||||
// ── Jobs handler ───────────────────────────────────────────────
|
||||
|
||||
async fn handle_jobs(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<JobsResponse>, (StatusCode, String)> {
|
||||
let _claims = extract_claims(&headers, &state)?;
|
||||
if !state.rate_limiter.check(&_claims.user_id).await {
|
||||
return Err((StatusCode::TOO_MANY_REQUESTS, "Rate limit exceeded".to_string()));
|
||||
}
|
||||
Ok(Json(JobsResponse { jobs: state.jobs.clone() }))
|
||||
}
|
||||
|
||||
// ── Health handler ─────────────────────────────────────────────
|
||||
|
||||
async fn handle_health(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Json<HealthResponse> {
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
model: state.model.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "crm_ai=info,tower_http=info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||
|
||||
let jobs_path = std::env::var("JOBS_PATH").unwrap_or_else(|_| "data/ai/jobs.jsonl".to_string());
|
||||
let jobs_content = fs::read_to_string(&jobs_path).unwrap_or_default();
|
||||
let jobs: Vec<Job> = jobs_content.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str(l).ok()).collect();
|
||||
|
||||
info!("Loaded {} job categories, model: {}, Ollama: {}", jobs.len(), model, ollama_url);
|
||||
|
||||
let db = PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.connect(&database_url)
|
||||
.await
|
||||
.expect("Failed to connect to database");
|
||||
|
||||
info!("Connected to PostgreSQL");
|
||||
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to build HTTP client");
|
||||
|
||||
let lead_store = Arc::new(Mutex::new(LeadStore::new(100)));
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
db,
|
||||
ollama_url,
|
||||
model,
|
||||
jobs,
|
||||
leads: lead_store.clone(),
|
||||
http_client,
|
||||
jwt_secret,
|
||||
rate_limiter: RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list([
|
||||
"http://localhost:3006".parse().unwrap(),
|
||||
"http://127.0.0.1:3006".parse().unwrap(),
|
||||
]))
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers(Any);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/ai/chat", post(handle_chat))
|
||||
.route("/ai/jobs", get(handle_jobs))
|
||||
.route("/health", get(handle_health))
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
let addr = format!("{}:{}", host, port);
|
||||
info!("CRM AI server listening on {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr)
|
||||
.await
|
||||
.expect("Failed to bind address");
|
||||
|
||||
let bg_leads = lead_store.clone();
|
||||
let bg_db = state.db.clone();
|
||||
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!("Failed to build background HTTP client: {} — scraper disabled", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Initial delay to let user on-demand requests take priority
|
||||
tokio::time::sleep(Duration::from_secs(300)).await;
|
||||
|
||||
loop {
|
||||
// 10% random cycle skip — makes pattern non-periodic
|
||||
if rand::thread_rng().gen_range(0..100) < 10 {
|
||||
info!("Skipping this scrape cycle (random 10% skip)");
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip night hours (23:00 – 06:00)
|
||||
let hour = chrono::Local::now().hour();
|
||||
if hour < 6 || hour >= 23 {
|
||||
info!("Night hours ({}) — skipping scrape", hour);
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800);
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
||||
|
||||
// Pick next active un-flagged account
|
||||
let account = sqlx::query_as::<_, (uuid::Uuid, String)>(
|
||||
"SELECT id, profile_path FROM facebook_accounts \
|
||||
WHERE is_active = TRUE AND flagged = FALSE \
|
||||
ORDER BY last_scrape_at ASC NULLS FIRST LIMIT 1"
|
||||
)
|
||||
.fetch_optional(&bg_db)
|
||||
.await;
|
||||
|
||||
match account {
|
||||
Ok(Some((account_id, profile_path))) => {
|
||||
match client.post(&bg_url).query(&[("profile_path", &profile_path)]).send().await {
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
match resp.json::<ScrapeResponse>().await {
|
||||
Ok(data) => {
|
||||
let leads_count = data.leads.len() as i32;
|
||||
if data.flagged {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET flagged = TRUE, flagged_at = NOW(), \
|
||||
flagged_reason = $2, last_error_at = NOW(), \
|
||||
last_error_message = $3, consecutive_failures = consecutive_failures + 1 \
|
||||
WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&data.flag_reason)
|
||||
.bind(&data.error)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
warn!("Facebook account {} flagged: {:?}", account_id, data.flag_reason);
|
||||
let reason = data.flag_reason.as_deref().unwrap_or("unknown");
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO notifications (user_id, type, title, description, link) \
|
||||
SELECT id, 'warning', 'Facebook Account Flagged', \
|
||||
$1 || ' - ' || COALESCE($2, 'unknown reason'), \
|
||||
NULL \
|
||||
FROM users u JOIN user_roles ur ON ur.user_id = u.id \
|
||||
JOIN roles r ON r.id = ur.role_id \
|
||||
WHERE r.name IN ('ADMIN', 'SUPER_ADMIN')"
|
||||
)
|
||||
.bind(&account_id.to_string())
|
||||
.bind(reason)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
} else if data.success {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET last_scrape_at = NOW(), \
|
||||
last_success_at = NOW(), consecutive_failures = 0, \
|
||||
updated_at = NOW() WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
|
||||
let mut store = bg_leads.lock().await;
|
||||
for item in &data.leads {
|
||||
store.push(Lead {
|
||||
title: truncate(&item.title, 120),
|
||||
url: item.url.clone(),
|
||||
source: item.source.clone().unwrap_or_else(|| "facebook".to_string()),
|
||||
found_at: now,
|
||||
author: truncate(&item.author, 60),
|
||||
date: truncate(&item.date, 30),
|
||||
content: truncate(&item.content, 300),
|
||||
});
|
||||
}
|
||||
info!("Scraped {} leads from Facebook account {}", leads_count, account_id);
|
||||
} else {
|
||||
// Increment failures; auto-flag if >= 3 consecutive
|
||||
let _ = sqlx::query(
|
||||
"UPDATE facebook_accounts SET last_error_at = NOW(), \
|
||||
last_error_message = $2, consecutive_failures = consecutive_failures + 1, \
|
||||
flagged = CASE WHEN consecutive_failures + 1 >= 3 THEN TRUE ELSE flagged END, \
|
||||
flagged_reason = CASE WHEN consecutive_failures + 1 >= 3 THEN 'too_many_failures' ELSE flagged_reason END, \
|
||||
flagged_at = CASE WHEN consecutive_failures + 1 >= 3 THEN NOW() ELSE flagged_at END, \
|
||||
updated_at = NOW() WHERE id = $1"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(&data.error)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
warn!("Facebook scrape failed for account {}: {:?}", account_id, data.error);
|
||||
}
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO facebook_scrape_logs \
|
||||
(account_id, started_at, completed_at, success, leads_found, error_message, detected_flag) \
|
||||
VALUES ($1, NOW() - interval '5 hours', NOW(), $2, $3, $4, $5)"
|
||||
)
|
||||
.bind(account_id)
|
||||
.bind(data.success && !data.flagged)
|
||||
.bind(leads_count)
|
||||
.bind(&data.error)
|
||||
.bind(&data.flag_reason)
|
||||
.execute(&bg_db)
|
||||
.await;
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to parse scraper JSON: {}", e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Scraper returned status: {}", resp.status());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Scraper request failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
info!("No active Facebook accounts available — skipping scrape cycle");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to query Facebook accounts: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let jitter = rand::thread_rng().gen_range(16200..19800); // 4.5h – 5.5h
|
||||
tokio::time::sleep(Duration::from_secs(jitter)).await;
|
||||
}
|
||||
});
|
||||
|
||||
axum::serve(listener, app)
|
||||
.await
|
||||
.expect("Server failed");
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
const express = require('express');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const PORT = process.env.PORT || 3007;
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.post('/scrape/indeed', async (req, res) => {
|
||||
const results = [];
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
});
|
||||
|
||||
const queries = [
|
||||
'need a website built',
|
||||
'looking for someone to build my website',
|
||||
'need web designer',
|
||||
'looking for web developer',
|
||||
'need help with my website',
|
||||
'need ecommerce website',
|
||||
'need wordpress website',
|
||||
'looking for website designer',
|
||||
];
|
||||
|
||||
for (const q of queries) {
|
||||
const url = `https://www.indeed.com/jobs?q=${encodeURIComponent(q)}&sort=date`;
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const items = await page.evaluate(() => {
|
||||
const cards = document.querySelectorAll('.job_seen_beacon');
|
||||
return Array.from(cards).slice(0, 8).map(card => {
|
||||
const titleEl = card.querySelector('.jcs-JobTitle');
|
||||
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
|
||||
const snippetEl = card.querySelector('.job-snippet');
|
||||
return {
|
||||
title: titleEl?.textContent?.trim() || '',
|
||||
url: titleEl?.href || '',
|
||||
company: companyEl?.textContent?.trim() || '',
|
||||
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
for (const item of items) {
|
||||
if (item.title && item.url && !results.some(r => r.url === item.url)) {
|
||||
results.push({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
author: item.company,
|
||||
date: '',
|
||||
content: item.snippet,
|
||||
source: 'indeed',
|
||||
});
|
||||
}
|
||||
}
|
||||
await page.close();
|
||||
} catch (e) {
|
||||
console.error(`Indeed failed ${q}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
res.json(results.slice(0, 50));
|
||||
} catch (e) {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
console.error('Indeed error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/scrape/all', async (req, res) => {
|
||||
try {
|
||||
const [indeed] = await Promise.allSettled([
|
||||
fetch(`http://localhost:${PORT}/scrape/indeed`, { method: 'POST' }).then(r => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
const all = [
|
||||
...(indeed.status === 'fulfilled' ? indeed.value : []),
|
||||
];
|
||||
console.log(`Scrape all: ${all.length} leads`);
|
||||
res.json(all);
|
||||
} catch (e) {
|
||||
console.error('Scrape all error:', e.message);
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Scraper service running on port ${PORT}`);
|
||||
});
|
||||
@@ -1,872 +0,0 @@
|
||||
{
|
||||
"name": "scraper-service",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "scraper-service",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.48.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "scraper-service",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"install-playwright": "npx playwright install chromium"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"playwright": "^1.48.0"
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { AIChat } from "@/components/ai/ai-chat"
|
||||
import { JobSelector } from "@/components/ai/job-selector"
|
||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||
|
||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||
setSelectedJob(job)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
||||
<AIChat />
|
||||
</div>
|
||||
|
||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||
<Target className="h-3.5 w-3.5" />
|
||||
Target Job
|
||||
</h3>
|
||||
<JobSelector onSelect={handleJobSelect} />
|
||||
</div>
|
||||
|
||||
{selectedJob && (
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedJob.keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
||||
{kw}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
||||
<Lightbulb className="h-3.5 w-3.5" />
|
||||
Tips
|
||||
</h4>
|
||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for cold email templates for a specific job
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Request objection handling tips
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
||||
Ask for outreach strategies per industry
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { StatCard } from "@/components/dashboard/stat-card"
|
||||
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
|
||||
import { RecentLeadsTable } from "@/components/dashboard/recent-leads-table"
|
||||
import { LeadStatusChart } from "@/components/dashboard/lead-status-chart"
|
||||
import { LeadsPerMonthChart } from "@/components/dashboard/leads-per-month-chart"
|
||||
import {
|
||||
Users,
|
||||
UserPlus,
|
||||
PhoneCall,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
TrendingUp,
|
||||
ListFilter,
|
||||
} from "lucide-react"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DashboardStats } from "@/types"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
async function fetchStats(p: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?period=${p}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setStats(data)
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch dashboard stats")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats(period)
|
||||
pollingRef.current = setInterval(() => fetchStats(period), 30000)
|
||||
return () => {
|
||||
if (pollingRef.current) clearInterval(pollingRef.current)
|
||||
}
|
||||
}, [period])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
{ title: "Total Leads", value: stats.totalLeads, icon: Users, description: stats.periodLabel, trend: stats.trends.totalLeads, sparklineField: "total" as const },
|
||||
{ title: "Open Leads", value: stats.openLeads, icon: UserPlus, description: "Needs attention", trend: stats.trends.openLeads, sparklineField: "open" as const },
|
||||
{ title: "Contacted", value: stats.contactedLeads, icon: PhoneCall, description: "In conversation", trend: stats.trends.contactedLeads, sparklineField: "contacted" as const },
|
||||
{ title: "Pending", value: stats.pendingLeads, icon: Clock, description: "Awaiting decision", trend: stats.trends.pendingLeads, sparklineField: "pending" as const },
|
||||
{ title: "Closed", value: stats.closedLeads, icon: CheckCircle2, description: "Won", trend: stats.trends.closedLeads, sparklineField: "closed" as const },
|
||||
{ title: "Conversion Rate", value: `${stats.conversionRate}%`, icon: TrendingUp, description: `${stats.closedLeads} of ${stats.totalLeads} leads`, trend: stats.trends.conversionRate, sparklineField: "closed" as const, conversionRate: stats.conversionRate },
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
{/* Daily Bugle watermark */}
|
||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0" style={{opacity: 0.15}}>
|
||||
<span className="text-[96px] font-['Bangers',cursive] leading-none tracking-wider" style={{color: "#e62020"}}>
|
||||
DAILY BUGLE
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
||||
<Select value={period} onValueChange={setPeriod}>
|
||||
<SelectTrigger className="h-9 w-[160px]">
|
||||
<ListFilter className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PageHeader>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
? statCards.map((card, i) => (
|
||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||
))
|
||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||
</div>
|
||||
|
||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { AppShell } from "@/components/layout/app-shell"
|
||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||
import { NotificationProvider } from "@/providers/notification-provider"
|
||||
import { ErrorBoundary } from "@/components/shared/error-boundary"
|
||||
import { Loader2 } from "lucide-react"
|
||||
|
||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||
const { user, loading } = useUser()
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return <AppShell>{children}</AppShell>
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<UserProvider>
|
||||
<NotificationProvider>
|
||||
<ErrorBoundary>
|
||||
<DashboardContent>{children}</DashboardContent>
|
||||
</ErrorBoundary>
|
||||
</NotificationProvider>
|
||||
</UserProvider>
|
||||
)
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { use } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadDetailsCard } from "@/components/leads/lead-details-card"
|
||||
import { LeadStatusBadge } from "@/components/leads/lead-status-badge"
|
||||
import { NoteTimeline } from "@/components/notes/note-timeline"
|
||||
import { NoteForm } from "@/components/notes/note-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { ArrowLeft, Edit, ExternalLink } from "lucide-react"
|
||||
import { Lead, Note } from "@/types"
|
||||
|
||||
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)
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
const notesRes = await fetch(`/api/leads/${id}/notes`)
|
||||
if (notesRes.ok) setLeadNotes(await notesRes.json())
|
||||
}, [id])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadRes] = await Promise.all([
|
||||
fetch(`/api/leads/${id}`),
|
||||
fetchNotes(),
|
||||
])
|
||||
if (leadRes.ok) setLead(await leadRes.json())
|
||||
} catch {
|
||||
console.warn("Failed to fetch lead details")
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
fetchData()
|
||||
}, [id, fetchNotes])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[60vh]">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold">Lead not found</h2>
|
||||
<p className="mt-2 text-muted-foreground">The lead you are looking for does not exist.</p>
|
||||
<Link href="/leads" className="mt-4 inline-block">
|
||||
<Button variant="outline">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Leads
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{lead.companyName}</span>
|
||||
<LeadStatusBadge status={lead.status} />
|
||||
</div>
|
||||
}
|
||||
description={lead.contactName}
|
||||
>
|
||||
<Select
|
||||
value={lead.status}
|
||||
onValueChange={async (v) => {
|
||||
const previousStatus = lead.status
|
||||
setLead((prev) => prev ? { ...prev, status: v as Lead["status"] } : prev)
|
||||
try {
|
||||
const res = await fetch(`/api/leads/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: v }),
|
||||
})
|
||||
if (!res.ok) setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
} catch {
|
||||
setLead((prev) => prev ? { ...prev, status: previousStatus } : prev)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="contacted">Contacted</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
<SelectItem value="ignored">Ignored</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" size="sm">
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<LeadDetailsCard lead={lead} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.1 }}
|
||||
>
|
||||
<NoteForm leadId={lead.id} onNoteAdded={fetchNotes} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
>
|
||||
<NoteTimeline notes={leadNotes} />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Activity</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="font-medium">Lead Created</p>
|
||||
<p className="text-xs text-muted-foreground">{new Date(lead.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{leadNotes.slice(0, 3).map((note) => (
|
||||
<div key={note.id} className="flex items-start gap-3 text-sm">
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground mt-1.5" />
|
||||
<div>
|
||||
<p className="font-medium">{note.authorName}</p>
|
||||
<p className="text-xs text-muted-foreground">{note.note.slice(0, 60)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.25 }}
|
||||
className="rounded-lg border bg-card p-6"
|
||||
>
|
||||
<h3 className="font-semibold mb-4">Quick Actions</h3>
|
||||
<div className="space-y-2">
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Send Email
|
||||
</Button>
|
||||
<Button variant="outline" className="w-full justify-start" size="sm">
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Call Lead
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { User } from "@/types"
|
||||
import { useNotifications } from "@/providers/notification-provider"
|
||||
import { LEAD_SOURCES, LEAD_STATUSES } from "@/lib/constants"
|
||||
|
||||
const leadFormSchema = z.object({
|
||||
companyName: z.string().min(1, "Company name is required"),
|
||||
contactName: z.string().min(1, "Contact name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
status: z.string(),
|
||||
assignedUserId: z.string().optional(),
|
||||
})
|
||||
|
||||
type LeadFormValues = z.infer<typeof leadFormSchema>
|
||||
|
||||
export default function CreateLeadPage() {
|
||||
const router = useRouter()
|
||||
const { addNotification } = useNotifications()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/users")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const form = useForm<LeadFormValues>({
|
||||
resolver: zodResolver(leadFormSchema),
|
||||
defaultValues: {
|
||||
companyName: "",
|
||||
contactName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
source: "",
|
||||
description: "",
|
||||
status: "open",
|
||||
assignedUserId: "none",
|
||||
},
|
||||
})
|
||||
|
||||
async function onSubmit(values: LeadFormValues) {
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
...values,
|
||||
assignedUserId: values.assignedUserId === "none" ? null : (values.assignedUserId ?? null),
|
||||
}
|
||||
const res = await fetch("/api/leads", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to create lead")
|
||||
const data = await res.json()
|
||||
addNotification("lead_created", "New Lead Created", `${values.companyName} — ${values.contactName}`, `/leads/${data.id}`)
|
||||
router.push("/leads")
|
||||
} catch (e) {
|
||||
console.error("Create lead error:", e)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Link
|
||||
href="/leads"
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to leads
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Create New Lead</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Fill in the details to add a new lead.</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="companyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Company Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Acme Corp" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="contactName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Contact Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="john@acme.com" type="email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Phone</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="(555) 123-4567" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Source</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select source" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{LEAD_SOURCES.map((source) => (
|
||||
<SelectItem key={source} value={source}>
|
||||
{source}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(LEAD_STATUSES).map(([key, { label }]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Brief description of the lead and their requirements..."
|
||||
className="min-h-[100px]"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="assignedUserId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Assign To</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select user" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">Unassigned</SelectItem>
|
||||
{users.filter((u) => u.active).map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.role})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<Button type="submit" disabled={saving}>
|
||||
{saving ? "Saving..." : "Create Lead"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push("/leads")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { LeadsTable } from "@/components/leads/leads-table"
|
||||
import { LeadsTableToolbar } from "@/components/leads/leads-table-toolbar"
|
||||
import { Lead } from "@/types"
|
||||
|
||||
export default function LeadsPage() {
|
||||
const router = useRouter()
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState("all")
|
||||
const [periodFilter, setPeriodFilter] = useState("all")
|
||||
const [leadsData, setLeadsData] = useState<Lead[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams()
|
||||
if (search) params.set("search", search)
|
||||
if (statusFilter !== "all") params.set("status", statusFilter)
|
||||
if (periodFilter !== "all") params.set("period", periodFilter)
|
||||
|
||||
setLoading(true)
|
||||
fetch(`/api/leads?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setLeadsData(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => setLoading(false))
|
||||
}, [search, statusFilter, periodFilter])
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Leads"
|
||||
description="Manage and track your sales leads"
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="p-4">
|
||||
<LeadsTableToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
periodFilter={periodFilter}
|
||||
onPeriodFilterChange={setPeriodFilter}
|
||||
onCreateClick={() => router.push("/leads/new")}
|
||||
/>
|
||||
</div>
|
||||
<LeadsTable data={leadsData} loading={loading} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useRef } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Mail, Calendar, Shield, Activity, Camera } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, updateAvatar } = useUser()
|
||||
if (!user) return null
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const validTypes = ["image/png", "image/jpeg"]
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error("Only PNG and JPEG files are allowed")
|
||||
return
|
||||
}
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
try {
|
||||
const res = await fetch("/api/users/avatar", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ avatar: dataUrl }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
updateAvatar(data.avatar)
|
||||
toast.success("Avatar updated")
|
||||
} else {
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to save avatar")
|
||||
toast.error("Failed to save avatar")
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Profile" description="Your account information" />
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<Card className="lg:col-span-1">
|
||||
<CardContent className="flex flex-col items-center pt-8">
|
||||
<div className="relative">
|
||||
<Avatar className="h-24 w-24">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback className="text-2xl">{user.name.split(" ").map((n) => n[0]).join("")}</AvatarFallback>
|
||||
</Avatar>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity hover:opacity-100"
|
||||
>
|
||||
<Camera className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".png,.jpeg,.jpg"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="mt-4 text-xl font-semibold">{user.name}</h2>
|
||||
<Badge variant="secondary" className="mt-1 capitalize">
|
||||
{user.role}
|
||||
</Badge>
|
||||
<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" />
|
||||
<span className="text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Joined {new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Shield className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground capitalize">{user.role} access</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-lg bg-muted/50 px-4 py-3">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{user.active ? "Active" : "Inactive"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Full Name</span>
|
||||
<p className="text-sm font-medium">{user.name}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Email</span>
|
||||
<p className="text-sm font-medium">{user.email}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Role</span>
|
||||
<p className="text-sm font-medium capitalize">{user.role}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<p className="text-sm font-medium">{user.active ? "Active" : "Inactive"}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">Member Since</span>
|
||||
<p className="text-sm font-medium">
|
||||
{new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-muted-foreground">User ID</span>
|
||||
<p className="text-sm font-medium font-mono">{user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { CompanySettingsForm } from "@/components/settings/company-settings-form"
|
||||
import { UserPreferencesForm } from "@/components/settings/user-preferences-form"
|
||||
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 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Settings"
|
||||
description="Manage your CRM configuration and preferences"
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="company" className="space-y-6">
|
||||
<TabsList>
|
||||
{tabs.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) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<tab.component />
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { PageHeader } from "@/components/shared/page-header"
|
||||
import { UsersTable } from "@/components/users/users-table"
|
||||
import { UserFormDialog } from "@/components/users/user-form-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { useUser } from "@/providers/user-provider"
|
||||
import { Plus, Users as UsersIcon, Search } from "lucide-react"
|
||||
import type { User } from "@/types"
|
||||
|
||||
export default function UsersPage() {
|
||||
const { user } = useUser()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/users")
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setUsers(data.users || [])
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch users in users page")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers()
|
||||
}, [fetchUsers])
|
||||
|
||||
const activeUsers = users.filter((u) => u.active !== false)
|
||||
const admins = users.filter((u) => u.role === "admin")
|
||||
|
||||
const stats = [
|
||||
{ label: "Total Users", value: users.length, color: "bg-blue-500/10", textColor: "text-blue-500" },
|
||||
{ label: "Active", value: activeUsers.length, color: "bg-green-500/10", textColor: "text-green-500" },
|
||||
{ label: "Admins", value: admins.length, color: "bg-purple-500/10", textColor: "text-purple-500" },
|
||||
{ label: "Sales", value: users.filter((u) => u.role === "sales").length, color: "bg-orange-500/10", textColor: "text-orange-500" },
|
||||
]
|
||||
|
||||
const filtered = users.filter((u) => {
|
||||
if (!search) return true
|
||||
const q = search.toLowerCase()
|
||||
return u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Manage team members and their roles"
|
||||
>
|
||||
<Button className="gap-2" onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add User
|
||||
</Button>
|
||||
</PageHeader>
|
||||
|
||||
<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">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${s.color}`}
|
||||
>
|
||||
<UsersIcon className={`h-5 w-5 ${s.textColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{s.value}</p>
|
||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card">
|
||||
<UsersTable data={filtered} loading={loading} onUserDeleted={fetchUsers} />
|
||||
</div>
|
||||
|
||||
<UserFormDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onUserCreated={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
|
||||
// This route handler has a known issue with Next.js 15 fetch augmentation
|
||||
// on this platform. The client-side code calls the AI server directly
|
||||
// via browser fetch (CORS is open). This handler returns a fallback.
|
||||
export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: "AI service unavailable on server. Use client-side mode." }, { status: 503 })
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { fetchJobs } from "@/lib/ai"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
if (!["sales", "admin", "super_admin"].includes(user.role)) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const jobs = await fetchJobs()
|
||||
return NextResponse.json({ jobs })
|
||||
} catch {
|
||||
console.warn("Failed to fetch AI jobs in API route")
|
||||
return NextResponse.json({ jobs: [] })
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
comparePassword,
|
||||
getUserByEmail,
|
||||
getUserByUsername,
|
||||
mapDbUserToSessionUser,
|
||||
recordLoginAttempt,
|
||||
incrementFailedAttempts,
|
||||
resetFailedAttempts,
|
||||
isAccountLocked,
|
||||
createSession,
|
||||
} from "@/lib/auth"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, username, password } = await request.json()
|
||||
|
||||
const credential = email || username
|
||||
|
||||
if (!credential || !password) {
|
||||
return NextResponse.json(
|
||||
{ error: "Email/Username and password are required." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: "Credentials cannot be empty." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || null
|
||||
|
||||
// Try to find user by email first, then by username
|
||||
let dbUser =
|
||||
email || credential.includes("@")
|
||||
? await getUserByEmail(credential)
|
||||
: null
|
||||
|
||||
if (!dbUser) {
|
||||
dbUser = await getUserByUsername(credential)
|
||||
}
|
||||
|
||||
if (!dbUser) {
|
||||
await recordLoginAttempt(
|
||||
null,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
"User not found"
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const lockStatus = await isAccountLocked(dbUser)
|
||||
if (lockStatus.locked) {
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
lockStatus.reason
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: lockStatus.reason },
|
||||
{ status: 423 }
|
||||
)
|
||||
}
|
||||
|
||||
const valid = await comparePassword(password, dbUser.password_hash)
|
||||
if (!valid) {
|
||||
await incrementFailedAttempts(dbUser.id)
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
false,
|
||||
"Invalid password"
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await resetFailedAttempts(dbUser.id)
|
||||
await recordLoginAttempt(
|
||||
dbUser.id,
|
||||
credential,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
true
|
||||
)
|
||||
|
||||
await createSession(dbUser.id, dbUser.role_name)
|
||||
|
||||
const user = mapDbUserToSessionUser(dbUser)
|
||||
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { destroySession } from "@/lib/auth"
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
await destroySession()
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Logout failed." },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Not authenticated." }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Auth me error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const { searchParams } = new URL(_request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "100", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const before = searchParams.get("before") || ""
|
||||
|
||||
// Verify user is a participant
|
||||
const partCheck = await query(
|
||||
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id]
|
||||
)
|
||||
if (partCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||
}
|
||||
|
||||
let msgSql = `SELECT m.id, m.sender_id, m.content, m.created_at, m.updated_at, m.deleted_at,
|
||||
u.first_name || ' ' || u.last_name AS sender_name,
|
||||
u.email AS sender_email,
|
||||
u.avatar_url AS sender_avatar_url
|
||||
FROM messages m
|
||||
JOIN users u ON u.id = m.sender_id
|
||||
WHERE m.conversation_id = $1 AND m.deleted_at IS NULL`
|
||||
const msgParams: any[] = [id]
|
||||
|
||||
if (before) {
|
||||
msgSql += ` AND m.created_at < $2`
|
||||
msgParams.push(before)
|
||||
}
|
||||
|
||||
msgSql += ` ORDER BY m.created_at ASC`
|
||||
msgSql += ` LIMIT $${msgParams.length + 1} OFFSET $${msgParams.length + 2}`
|
||||
msgParams.push(limit, offset)
|
||||
|
||||
const [msgResult, otherReadResult] = await Promise.all([
|
||||
query(msgSql, msgParams),
|
||||
query(
|
||||
`SELECT last_read_at FROM conversation_participants
|
||||
WHERE conversation_id = $1 AND user_id != $2`,
|
||||
[id, user.id],
|
||||
),
|
||||
])
|
||||
|
||||
const otherLastReadAt = otherReadResult.rows[0]?.last_read_at
|
||||
? new Date(otherReadResult.rows[0].last_read_at).getTime()
|
||||
: 0
|
||||
|
||||
const messages = msgResult.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
conversationId: id,
|
||||
senderId: row.sender_id,
|
||||
senderName: row.sender_name,
|
||||
senderAvatar: avatarSvgUrl(row.sender_name),
|
||||
content: row.content,
|
||||
timestamp: formatTime(new Date(row.created_at)),
|
||||
createdAt: row.created_at,
|
||||
read: row.sender_id === user.id
|
||||
? new Date(row.created_at).getTime() <= otherLastReadAt
|
||||
: true,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ messages })
|
||||
} catch (error) {
|
||||
console.error("Messages error:", error)
|
||||
return NextResponse.json({ error: "Failed to load messages" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
// Verify user is a participant
|
||||
const partCheck = await query(
|
||||
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id]
|
||||
)
|
||||
if (partCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { content } = await request.json()
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, created_at`,
|
||||
[id, user.id, content.trim()],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE conversations SET updated_at = NOW() WHERE id = $1`,
|
||||
[id],
|
||||
)
|
||||
|
||||
const msg = result.rows[0]
|
||||
const senderName = `${user.firstName} ${user.lastName}`
|
||||
|
||||
const otherResult = await query(
|
||||
`SELECT user_id FROM conversation_participants
|
||||
WHERE conversation_id = $1 AND user_id != $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
for (const row of otherResult.rows) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, 'chat_message', 'New Message', $2, '/chats', $3, 'conversation')`,
|
||||
[row.user_id, `${senderName} sent a message`, id],
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
message: {
|
||||
id: msg.id,
|
||||
conversationId: id,
|
||||
senderId: user.id,
|
||||
senderName,
|
||||
senderAvatar: user.avatar,
|
||||
content: content.trim(),
|
||||
timestamp: formatTime(new Date(msg.created_at)),
|
||||
createdAt: msg.created_at,
|
||||
read: false,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Send message error:", error)
|
||||
return NextResponse.json({ error: "Failed to send message" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(date: Date): string {
|
||||
const now = new Date()
|
||||
const isToday = date.toDateString() === now.toDateString()
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
await query(
|
||||
`UPDATE conversation_participants
|
||||
SET last_read_at = NOW()
|
||||
WHERE conversation_id = $1 AND user_id = $2`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
await query(
|
||||
`UPDATE notifications SET is_read = TRUE
|
||||
WHERE user_id = $1 AND context_type = 'conversation' AND context_id = $2 AND is_read = FALSE`,
|
||||
[user.id, id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Mark read error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT
|
||||
c.id,
|
||||
c.updated_at,
|
||||
cp_me.last_read_at,
|
||||
u.id AS other_user_id,
|
||||
u.first_name || ' ' || u.last_name AS other_user_name,
|
||||
u.email AS other_user_email,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
(SELECT count(*) FROM messages WHERE conversation_id = c.id AND sender_id != $1 AND created_at > COALESCE(cp_me.last_read_at, '1970-01-01')) AS unread
|
||||
FROM conversations c
|
||||
JOIN conversation_participants cp_me ON cp_me.conversation_id = c.id AND cp_me.user_id = $1
|
||||
JOIN conversation_participants cp ON cp.conversation_id = c.id
|
||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT 50`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const conversations = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
updatedAt: row.updated_at,
|
||||
otherUser: {
|
||||
id: row.other_user_id,
|
||||
name: row.other_user_name,
|
||||
email: row.other_user_email,
|
||||
avatar: avatarSvgUrl(row.other_user_name),
|
||||
},
|
||||
lastMessage: row.last_message || "",
|
||||
lastMessageTime: row.last_message_time ? timeAgo(new Date(row.last_message_time)) : "",
|
||||
unread: parseInt(row.unread) || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({ conversations })
|
||||
} catch (error) {
|
||||
console.error("Conversations error:", error)
|
||||
return NextResponse.json({ error: "Failed to load conversations" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { userId } = await request.json()
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: "userId is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (userId === user.id) {
|
||||
return NextResponse.json({ error: "Cannot start a conversation with yourself" }, { status: 400 })
|
||||
}
|
||||
|
||||
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
|
||||
JOIN conversation_participants cp2 ON cp2.conversation_id = c.id AND cp2.user_id = $2
|
||||
LIMIT 1`,
|
||||
[user.id, userId],
|
||||
)
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||
}
|
||||
|
||||
const convResult = await query(
|
||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||
)
|
||||
const conversationId = convResult.rows[0].id
|
||||
|
||||
await query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
otherUser: {
|
||||
id: other.id,
|
||||
name: other.name,
|
||||
email: other.email,
|
||||
avatar: avatarSvgUrl(other.name),
|
||||
},
|
||||
lastMessage: "",
|
||||
lastMessageTime: "",
|
||||
unread: 0,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Create conversation error:", error)
|
||||
return NextResponse.json({ error: "Failed to create conversation" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(date: Date): string {
|
||||
const seconds = Math.floor((Date.now() - date.getTime()) / 1000)
|
||||
if (seconds < 60) return "now"
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return date.toLocaleDateString()
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } {
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days":
|
||||
start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months":
|
||||
start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months":
|
||||
default:
|
||||
start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function getPreviousPeriodRange(period: string, currentStart: Date): { start: Date; end: Date } {
|
||||
const end = new Date(currentStart)
|
||||
const diff = end.getTime() - currentStart.getTime()
|
||||
const start = new Date(end.getTime() - diff)
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const periodLabels: Record<string, string> = {
|
||||
"7days": "Last 7 days",
|
||||
"30days": "Last 30 days",
|
||||
"6months": "Last 6 months",
|
||||
"12months": "Last 12 months",
|
||||
}
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeadsInRange(start: Date, end: Date, userId?: string, isAdmin?: boolean) {
|
||||
const result = await query(
|
||||
`SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${isAdmin ? "" : "AND l.assigned_to = $3"}
|
||||
ORDER BY l.created_at DESC`,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), userId]
|
||||
)
|
||||
return result.rows.map((r: any) => ({
|
||||
...r,
|
||||
status: stageToStatus(r.stage_name),
|
||||
}))
|
||||
}
|
||||
|
||||
function countStatuses(leads: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
leads.forEach((l: any) => {
|
||||
const s = l.status as keyof typeof counts
|
||||
if (s in counts) counts[s]++
|
||||
})
|
||||
return counts
|
||||
}
|
||||
|
||||
function buildMonthlyBreakdown(leads: any[], period: string) {
|
||||
const { start, end } = getPeriodDateRange(period)
|
||||
const result: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[] = []
|
||||
const current = new Date(start)
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
|
||||
while (current <= end) {
|
||||
const label = isMonthly
|
||||
? current.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: current.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
|
||||
const ps = new Date(current)
|
||||
const pe = isMonthly
|
||||
? new Date(current.getFullYear(), current.getMonth() + 1, 0, 23, 59, 59)
|
||||
: (() => { const d = new Date(current); d.setHours(23, 59, 59, 999); return d })()
|
||||
|
||||
const inPeriod = leads.filter((l: any) => {
|
||||
const d = new Date(l.created_at)
|
||||
return d >= ps && d <= pe
|
||||
})
|
||||
|
||||
const counts = countStatuses(inPeriod)
|
||||
result.push({ label, total: inPeriod.length, ...counts })
|
||||
|
||||
if (isMonthly) current.setMonth(current.getMonth() + 1)
|
||||
else current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const period = searchParams.get("period") || "6months"
|
||||
const yearParam = searchParams.get("year")
|
||||
let start: Date, end: Date, prevRange: { start: Date; end: Date }
|
||||
if (yearParam) {
|
||||
const y = parseInt(yearParam)
|
||||
start = new Date(y, 0, 1)
|
||||
end = new Date(y, 11, 31, 23, 59, 59)
|
||||
prevRange = { start: new Date(y - 1, 0, 1), end: new Date(y - 1, 11, 31, 23, 59, 59) }
|
||||
} else {
|
||||
const r = getPeriodDateRange(period)
|
||||
start = r.start; end = r.end
|
||||
prevRange = getPreviousPeriodRange(period, start)
|
||||
}
|
||||
|
||||
const [currentLeads, prevLeads] = await Promise.all([
|
||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
||||
])
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
||||
|
||||
const trends = {
|
||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||
conversionRate: computeTrend(conversionRate,
|
||||
prevLeads.length > 0 ? Math.round((prevCounts.closed / prevLeads.length) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
totalLeads,
|
||||
openLeads: currentCounts.open,
|
||||
contactedLeads: currentCounts.contacted,
|
||||
pendingLeads: currentCounts.pending,
|
||||
closedLeads,
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
|
||||
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
|
||||
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
|
||||
],
|
||||
periodLabel: periodLabels[period] ?? "Selected period",
|
||||
}
|
||||
|
||||
return NextResponse.json(stats)
|
||||
} catch (error) {
|
||||
console.error("Dashboard API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load dashboard stats" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
async function checkLeadAccess(leadId: string, userId: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
`SELECT 1 FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||
AND (assigned_to = $2 OR EXISTS (
|
||||
SELECT 1 FROM user_roles ur JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = $2 AND r.name IN ('ADMIN', 'SUPER_ADMIN')
|
||||
))`,
|
||||
[leadId, userId]
|
||||
)
|
||||
return result.rows.length > 0
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
if (!await checkLeadAccess(id, user.id)) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const { content } = await request.json()
|
||||
if (!content?.trim()) {
|
||||
return NextResponse.json({ error: "Content is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`INSERT INTO customer_notes (customer_id, author_id, content)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[id, user.id, content.trim()]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Create note error:", error)
|
||||
return NextResponse.json({ error: "Failed to create note" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
if (!await checkLeadAccess(id, user.id)) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
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
|
||||
FROM customer_notes cn
|
||||
JOIN users u ON u.id = cn.author_id
|
||||
WHERE cn.customer_id = $1 AND cn.deleted_at IS NULL
|
||||
ORDER BY cn.created_at DESC
|
||||
LIMIT $2 OFFSET $3`,
|
||||
[id, limit, offset]
|
||||
)
|
||||
|
||||
const notes = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
leadId: id,
|
||||
userId: r.user_id,
|
||||
authorName: `${r.first_name} ${r.last_name}`,
|
||||
authorAvatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
note: r.content,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}))
|
||||
|
||||
return NextResponse.json(notes)
|
||||
} catch (error) {
|
||||
console.error("Lead notes API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notes" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
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,
|
||||
ls.name AS stage_name,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.id = $1 AND l.deleted_at IS NULL
|
||||
AND ($2 = true OR l.assigned_to = $3)`,
|
||||
[id, isAdmin, user.id]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
const lead = {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
|
||||
return NextResponse.json(lead)
|
||||
} catch (error) {
|
||||
console.error("Lead detail API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
function statusToStageName(status: string): string {
|
||||
switch (status) {
|
||||
case "open": return "New"
|
||||
case "contacted": return "Contacted"
|
||||
case "pending": return "Qualified"
|
||||
case "closed": return "Closed Won"
|
||||
case "ignored": return "Closed Lost"
|
||||
default: return "New"
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
// Verify access
|
||||
const accessCheck = await query(
|
||||
`SELECT id FROM leads WHERE id = $1 AND deleted_at IS NULL
|
||||
AND ($2 = true OR assigned_to = $3)`,
|
||||
[id, isAdmin, user.id]
|
||||
)
|
||||
if (accessCheck.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
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) }
|
||||
if (body.phone !== undefined) { fields.push(`phone = $${idx++}`); values.push(body.phone) }
|
||||
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"
|
||||
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) {
|
||||
const stageName = statusToStageName(body.status)
|
||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = $1", [stageName])
|
||||
if (stageResult.rows.length > 0) {
|
||||
fields.push(`stage_id = $${idx++}`)
|
||||
values.push(stageResult.rows[0].id)
|
||||
}
|
||||
}
|
||||
if (body.score !== undefined) {
|
||||
const score = Number(body.score)
|
||||
if (!isFinite(score) || score < 0 || score > 100) {
|
||||
return NextResponse.json({ error: "Score must be a number between 0 and 100" }, { status: 400 })
|
||||
}
|
||||
fields.push(`score = $${idx++}`)
|
||||
values.push(score)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
const sql = `UPDATE leads SET ${fields.join(", ")} WHERE id = $${idx} AND deleted_at IS NULL RETURNING id`
|
||||
const result = await query(sql, values)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id })
|
||||
} catch (error) {
|
||||
console.error("Lead PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
function stageToStatus(name: string): string {
|
||||
switch (name) {
|
||||
case "New": return "open"
|
||||
case "Contacted": return "contacted"
|
||||
case "Qualified":
|
||||
case "Interested":
|
||||
case "Demo Scheduled":
|
||||
case "Negotiation": return "pending"
|
||||
case "Closed Won": return "closed"
|
||||
case "Closed Lost": return "ignored"
|
||||
default: return "open"
|
||||
}
|
||||
}
|
||||
|
||||
function getPeriodDateRange(period: string): { start: Date; end: Date } | null {
|
||||
if (period === "all") return null
|
||||
const end = new Date()
|
||||
let start: Date
|
||||
switch (period) {
|
||||
case "7days": start = new Date(end); start.setDate(start.getDate() - 7); break
|
||||
case "30days": start = new Date(end); start.setDate(start.getDate() - 30); break
|
||||
case "12months": start = new Date(end); start.setFullYear(start.getFullYear() - 12); break
|
||||
case "6months": default: start = new Date(end); start.setMonth(start.getMonth() - 6); break
|
||||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search") || ""
|
||||
const status = searchParams.get("status") || "all"
|
||||
const period = searchParams.get("period") || "all"
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
let sql = `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,
|
||||
u.id AS user_id, u.first_name, u.last_name, u.email AS user_email, u.avatar_url
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
LEFT JOIN users u ON u.id = l.assigned_to
|
||||
WHERE l.deleted_at IS NULL`
|
||||
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
params.push(range.start.toISOString(), range.end.toISOString())
|
||||
paramIdx += 2
|
||||
}
|
||||
}
|
||||
|
||||
if (search) {
|
||||
sql += ` AND (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++
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
||||
params.push(user.id)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
sql += ` ORDER BY l.created_at DESC`
|
||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
params.push(limit, offset)
|
||||
paramIdx += 2
|
||||
|
||||
const result = await query(sql, params)
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
email: r.email || "",
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: s,
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.user_email,
|
||||
avatar: avatarSvgUrl(`${r.first_name} ${r.last_name}`),
|
||||
} : null,
|
||||
createdAt: r.created_at,
|
||||
updatedAt: r.updated_at,
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
// Non-admin users can only assign leads to themselves; admin/super_admin can assign to anyone
|
||||
let assignedUserId = body.assignedUserId
|
||||
if (!isAdmin) {
|
||||
assignedUserId = user.id
|
||||
} else if (assignedUserId === "none" || !assignedUserId) {
|
||||
assignedUserId = null
|
||||
}
|
||||
|
||||
const stageResult = await query(
|
||||
"SELECT id FROM lead_stages WHERE name = $1",
|
||||
[body.status === "open" ? "New" : "Contacted"]
|
||||
)
|
||||
const stageId = stageResult.rows[0]?.id || 1
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO leads (company_name, contact_name, email, phone, notes, source_id, stage_id, assigned_to, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING id`,
|
||||
[
|
||||
body.companyName,
|
||||
body.contactName,
|
||||
body.email,
|
||||
body.phone || null,
|
||||
body.description || null,
|
||||
body.source || null,
|
||||
stageId,
|
||||
assignedUserId,
|
||||
]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Leads POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const id = searchParams.get("id")
|
||||
if (!id) return NextResponse.json({ error: "id is required" }, { status: 400 })
|
||||
|
||||
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]
|
||||
)
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return NextResponse.json({ error: "Lead not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Leads DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to delete lead" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`UPDATE notifications SET is_read = TRUE WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notification PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark notification as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const result = await query(
|
||||
`DELETE FROM notifications WHERE id = $1 AND user_id = $2 RETURNING id`,
|
||||
[id, user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({ error: "Notification not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notification DELETE error:", error)
|
||||
return NextResponse.json({ error: "Failed to dismiss notification" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT lead_assigned, lead_status, note_added, daily_digest, weekly_report
|
||||
FROM notification_preferences
|
||||
WHERE user_id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({
|
||||
leadAssigned: true,
|
||||
leadStatus: true,
|
||||
noteAdded: false,
|
||||
dailyDigest: false,
|
||||
weeklyReport: true,
|
||||
})
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
return NextResponse.json({
|
||||
leadAssigned: r.lead_assigned,
|
||||
leadStatus: r.lead_status,
|
||||
noteAdded: r.note_added,
|
||||
dailyDigest: r.daily_digest,
|
||||
weeklyReport: r.weekly_report,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Preferences GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
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())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET lead_assigned = $2, lead_status = $3, note_added = $4,
|
||||
daily_digest = $5, weekly_report = $6, updated_at = NOW()`,
|
||||
[
|
||||
user.id,
|
||||
body.leadAssigned ?? true,
|
||||
body.leadStatus ?? true,
|
||||
body.noteAdded ?? false,
|
||||
body.dailyDigest ?? false,
|
||||
body.weeklyReport ?? true,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Preferences PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, type, title, description, link, is_read, created_at, context_id, context_type
|
||||
FROM notifications
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const notifications = result.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
link: r.link,
|
||||
read: r.is_read,
|
||||
timestamp: r.created_at,
|
||||
contextId: r.context_id,
|
||||
contextType: r.context_type,
|
||||
}))
|
||||
|
||||
const unreadResult = await query(
|
||||
`SELECT COUNT(*) AS count FROM notifications WHERE user_id = $1 AND is_read = FALSE`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
notifications,
|
||||
unreadCount: parseInt(unreadResult.rows[0].count, 10),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Notifications GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load notifications" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { type, title, description, link, userId } = await request.json()
|
||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||
const targetUserId = (userId && isAdmin) ? userId : user.id
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, type, title, description, link, is_read, created_at`,
|
||||
[targetUserId, type, title, description || null, link || null],
|
||||
)
|
||||
|
||||
const notif = result.rows[0]
|
||||
return NextResponse.json({
|
||||
id: notif.id,
|
||||
type: notif.type,
|
||||
title: notif.title,
|
||||
description: notif.description,
|
||||
link: notif.link,
|
||||
read: notif.is_read,
|
||||
timestamp: notif.created_at,
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Notifications POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create notification" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
await query(
|
||||
`UPDATE notifications SET is_read = TRUE WHERE user_id = $1 AND is_read = FALSE`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Notifications PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to mark all as read" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const result = await query(`SELECT * FROM company_settings ORDER BY updated_at DESC LIMIT 1`)
|
||||
const row = result.rows[0]
|
||||
|
||||
if (!row) {
|
||||
return NextResponse.json({
|
||||
companyName: "Coastal IT Solutions",
|
||||
companyEmail: "info@coastalit.com",
|
||||
companyPhone: "(555) 123-4567",
|
||||
companyWebsite: "https://coastalit.com",
|
||||
companyAddress: "123 Business Ave, Suite 100, San Francisco, CA 94105",
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
companyName: row.company_name || "",
|
||||
companyEmail: row.company_email || "",
|
||||
companyPhone: row.company_phone || "",
|
||||
companyWebsite: row.company_website || "",
|
||||
companyAddress: row.company_address || "",
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Company settings GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load company settings" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
await query(
|
||||
`UPDATE company_settings SET
|
||||
company_name = $1, company_email = $2, company_phone = $3,
|
||||
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
|
||||
[
|
||||
body.companyName || "",
|
||||
body.companyEmail || "",
|
||||
body.companyPhone || "",
|
||||
body.companyWebsite || "",
|
||||
body.companyAddress || "",
|
||||
user.id,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Company settings PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save company settings" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const body = await request.json()
|
||||
const fields: string[] = []
|
||||
const values: any[] = []
|
||||
let idx = 1
|
||||
|
||||
if (body.isActive !== undefined) {
|
||||
fields.push(`is_active = $${idx++}`)
|
||||
values.push(body.isActive)
|
||||
}
|
||||
if (body.label !== undefined) {
|
||||
fields.push(`label = $${idx++}`)
|
||||
values.push(body.label.trim())
|
||||
}
|
||||
if (body.profilePath !== undefined) {
|
||||
fields.push(`profile_path = $${idx++}, cookie_file = $${idx}`)
|
||||
values.push(body.profilePath.trim())
|
||||
values.push(`${body.profilePath.replace(/\\+$/, '')}\\cookies.sqlite`)
|
||||
idx++
|
||||
}
|
||||
if (body.unflag === true) {
|
||||
fields.push(`flagged = FALSE, flagged_at = NULL, flagged_reason = NULL, consecutive_failures = 0`)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return NextResponse.json({ error: "No fields to update" }, { status: 400 })
|
||||
}
|
||||
|
||||
fields.push(`updated_at = NOW()`)
|
||||
values.push(id)
|
||||
|
||||
await query(
|
||||
`UPDATE facebook_accounts SET ${fields.join(", ")} WHERE id = $${idx}`,
|
||||
values
|
||||
)
|
||||
|
||||
const updated = await query(
|
||||
`SELECT id, label, profile_path, is_active, flagged, flagged_reason,
|
||||
consecutive_failures, updated_at
|
||||
FROM facebook_accounts WHERE id = $1`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json(updated.rows[0] || { success: true })
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to update Facebook account" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const accounts = await query(
|
||||
`SELECT fa.id, fa.label, fa.profile_path, fa.is_active,
|
||||
fa.last_scrape_at, fa.last_success_at, fa.last_error_at,
|
||||
fa.last_error_message, fa.consecutive_failures,
|
||||
fa.flagged, fa.flagged_at, fa.flagged_reason,
|
||||
fa.created_at, fa.updated_at,
|
||||
COALESCE(sl.leads_found, 0) AS last_leads_found,
|
||||
sl.success AS last_success,
|
||||
sl.detected_flag AS last_detected_flag
|
||||
FROM facebook_accounts fa
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT leads_found, success, detected_flag
|
||||
FROM facebook_scrape_logs
|
||||
WHERE account_id = fa.id
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
) sl ON TRUE
|
||||
ORDER BY fa.created_at ASC`
|
||||
)
|
||||
|
||||
return NextResponse.json(accounts.rows)
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load Facebook accounts" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { label, profilePath } = await request.json()
|
||||
if (!label?.trim() || !profilePath?.trim()) {
|
||||
return NextResponse.json({ error: "Label and profile path are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const cookieFile = `${profilePath.replace(/\\+$/, '')}\\cookies.sqlite`
|
||||
const result = await query(
|
||||
`INSERT INTO facebook_accounts (label, profile_path, cookie_file)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, label, profile_path, is_active, created_at`,
|
||||
[label.trim(), profilePath.trim(), cookieFile]
|
||||
)
|
||||
|
||||
return NextResponse.json(result.rows[0], { status: 201 })
|
||||
} catch (error) {
|
||||
console.error("Facebook accounts POST error:", error)
|
||||
return NextResponse.json({ error: "Failed to create Facebook account" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (user.role !== "admin" && user.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const accountId = searchParams.get("accountId")
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
let sql = `SELECT sl.id, sl.account_id, fa.label AS account_label,
|
||||
sl.started_at, sl.completed_at, sl.success,
|
||||
sl.leads_found, sl.error_message, sl.detected_flag,
|
||||
sl.created_at
|
||||
FROM facebook_scrape_logs sl
|
||||
JOIN facebook_accounts fa ON fa.id = sl.account_id`
|
||||
const params: any[] = []
|
||||
let paramIdx = 1
|
||||
|
||||
if (accountId) {
|
||||
sql += ` WHERE sl.account_id = $${paramIdx++}`
|
||||
params.push(accountId)
|
||||
}
|
||||
|
||||
sql += ` ORDER BY sl.created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`
|
||||
params.push(limit, offset)
|
||||
|
||||
const result = await query(sql, params)
|
||||
return NextResponse.json(result.rows)
|
||||
} catch (error) {
|
||||
console.error("Facebook scrape logs GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load scrape logs" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT timezone, date_format, items_per_page FROM user_preferences WHERE user_id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return NextResponse.json({
|
||||
timezone: "america-los_angeles",
|
||||
dateFormat: "mdy",
|
||||
itemsPerPage: 20,
|
||||
})
|
||||
}
|
||||
|
||||
const r = result.rows[0]
|
||||
return NextResponse.json({
|
||||
timezone: r.timezone,
|
||||
dateFormat: r.date_format,
|
||||
itemsPerPage: r.items_per_page,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Preferences GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
|
||||
await query(
|
||||
`INSERT INTO user_preferences (user_id, timezone, date_format, items_per_page, updated_at)
|
||||
VALUES ($1, $2, $3, $4, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET timezone = $2, date_format = $3, items_per_page = $4, updated_at = NOW()`,
|
||||
[
|
||||
user.id,
|
||||
body.timezone || "america-los_angeles",
|
||||
body.dateFormat || "mdy",
|
||||
body.itemsPerPage || 20,
|
||||
],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Preferences PATCH error:", error)
|
||||
return NextResponse.json({ error: "Failed to save preferences" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import os from "os"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
let prevCpu = process.cpuUsage()
|
||||
let prevTime = Date.now()
|
||||
|
||||
export async function GET() {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const elapsed = now - prevTime
|
||||
const currentCpu = process.cpuUsage()
|
||||
|
||||
const user = currentCpu.user - prevCpu.user
|
||||
const sys = currentCpu.system - prevCpu.system
|
||||
const totalUs = user + sys
|
||||
|
||||
// CPU time (ms) / wall time (ms) * 100 = % of one core
|
||||
const cpuPct = elapsed > 0 ? Math.round((totalUs / 1000) / elapsed * 100 * 10) / 10 : 0
|
||||
|
||||
prevCpu = currentCpu
|
||||
prevTime = now
|
||||
|
||||
const mem = process.memoryUsage()
|
||||
|
||||
return NextResponse.json({
|
||||
rssMB: Math.round(mem.rss / 1024 / 1024),
|
||||
cpuPct,
|
||||
cores: os.cpus().length,
|
||||
})
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only super admins can delete users" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
if (id === sessionUser.id) {
|
||||
return NextResponse.json({ error: "Cannot delete yourself" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[id]
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error)
|
||||
return NextResponse.json({ error: "Failed to delete user" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
const ALLOWED_PREFIXES = ["data:image/png;base64,", "data:image/jpeg;base64,", "data:image/gif;base64,"]
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { avatar } = await request.json()
|
||||
if (!avatar || typeof avatar !== "string") {
|
||||
return NextResponse.json({ error: "Invalid avatar data" }, { status: 400 })
|
||||
}
|
||||
|
||||
const allowed = ALLOWED_PREFIXES.some((p) => avatar.startsWith(p))
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Avatar must be a PNG, JPEG, or GIF data URL" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Approximate decoded size: base64 is ~4/3 of original
|
||||
const base64Data = avatar.split(",")[1] || ""
|
||||
const estimatedBytes = Math.round(base64Data.length * 0.75)
|
||||
if (estimatedBytes > MAX_AVATAR_BYTES) {
|
||||
return NextResponse.json({ error: "Avatar exceeds 2MB size limit" }, { status: 400 })
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||
[avatar, user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ avatar })
|
||||
} catch (error) {
|
||||
console.error("Avatar upload error:", error)
|
||||
return NextResponse.json({ error: "Failed to update avatar" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { query } from "@/lib/db"
|
||||
import { hashPassword, getSessionUser } from "@/lib/auth"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const limit = parseInt(searchParams.get("limit") || "50", 10)
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
|
||||
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,
|
||||
r.name AS role
|
||||
FROM users u
|
||||
JOIN user_roles ur ON ur.user_id = u.id
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.deleted_at IS NULL
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
)
|
||||
const users = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: `${row.first_name} ${row.last_name}`,
|
||||
email: row.email,
|
||||
role: row.role.toLowerCase(),
|
||||
active: row.active,
|
||||
avatar: avatarSvgUrl(`${row.first_name} ${row.last_name}`),
|
||||
createdAt: row.created_at,
|
||||
}))
|
||||
return NextResponse.json({ users }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("Error fetching users:", error)
|
||||
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Not authenticated" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only super admins can create users" }, { status: 403 })
|
||||
}
|
||||
|
||||
const { name, email, password, role, active } = await request.json()
|
||||
if (!name || !email || !password || !role) {
|
||||
return NextResponse.json({ error: "Name, email, password, and role are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const validRoles = ["sales", "admin", "super_admin", "dev"]
|
||||
if (!validRoles.includes(role)) {
|
||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||
}
|
||||
|
||||
const nameParts = name.trim().split(/\s+/)
|
||||
const firstName = nameParts[0]
|
||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||
const username = email.split("@")[0]
|
||||
const passwordHash = await hashPassword(password)
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO users (username, email, password_hash, first_name, last_name, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
[username.toLowerCase(), email.toLowerCase(), passwordHash, firstName, lastName, active ?? true, sessionUser.id]
|
||||
)
|
||||
|
||||
const roleId = (
|
||||
await query(`SELECT id FROM roles WHERE LOWER(name) = LOWER($1)`, [role])
|
||||
).rows[0]?.id
|
||||
|
||||
if (roleId) {
|
||||
await query(
|
||||
`INSERT INTO user_roles (user_id, role_id, assigned_by)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[result.rows[0].id, roleId, sessionUser.id]
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id: result.rows[0].id }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error("Error creating user:", error)
|
||||
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 })
|
||||
}
|
||||
return NextResponse.json({ error: error?.message || "Failed to create user" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const currentUser = await getSessionUser()
|
||||
if (!currentUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
if (currentUser.role !== "admin" && currentUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const q = request.nextUrl.searchParams.get("q") || ""
|
||||
|
||||
if (!q.trim()) {
|
||||
return NextResponse.json({ users: [] })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND id != $1
|
||||
AND (LOWER(first_name || ' ' || last_name) LIKE LOWER($2)
|
||||
OR LOWER(email) LIKE LOWER($2))
|
||||
ORDER BY first_name ASC
|
||||
LIMIT 10`,
|
||||
[currentUser.id, `%${q}%`],
|
||||
)
|
||||
|
||||
const users = result.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
avatar: avatarSvgUrl(row.name),
|
||||
}))
|
||||
|
||||
return NextResponse.json({ users })
|
||||
} catch (error) {
|
||||
console.error("User search error:", error)
|
||||
return NextResponse.json({ error: "Search failed" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,837 +0,0 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bangers&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes loading {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(400%); }
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 210 40% 98%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 0 100% 40%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 217.2 91.2% 59.8%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 224.3 76.3% 48%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 0 100% 53%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 0 100% 53%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 0 100% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 0 100% 53%;
|
||||
}
|
||||
|
||||
.ocean {
|
||||
--primary: 187 75% 42%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 187 75% 42%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 187 75% 42%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 187 30% 94%;
|
||||
--sidebar-accent-foreground: 187 50% 20%;
|
||||
--sidebar-border: 187 20% 88%;
|
||||
--sidebar-ring: 187 75% 42%;
|
||||
}
|
||||
|
||||
.dark.ocean {
|
||||
--primary: 187 75% 50%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 187 75% 50%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 187 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 187 75% 50%;
|
||||
}
|
||||
|
||||
.forest {
|
||||
--primary: 142 76% 36%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 142 76% 36%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 142 76% 36%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 142 30% 94%;
|
||||
--sidebar-accent-foreground: 142 50% 20%;
|
||||
--sidebar-border: 142 20% 88%;
|
||||
--sidebar-ring: 142 76% 36%;
|
||||
}
|
||||
|
||||
.dark.forest {
|
||||
--primary: 142 76% 44%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 142 76% 44%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 142 76% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 142 76% 44%;
|
||||
}
|
||||
|
||||
.sunset {
|
||||
--primary: 24 95% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 24 95% 53%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 24 95% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 24 30% 94%;
|
||||
--sidebar-accent-foreground: 24 50% 20%;
|
||||
--sidebar-border: 24 20% 88%;
|
||||
--sidebar-ring: 24 95% 53%;
|
||||
}
|
||||
|
||||
.dark.sunset {
|
||||
--primary: 24 95% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 24 95% 58%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 24 95% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 24 95% 58%;
|
||||
}
|
||||
|
||||
.midnight {
|
||||
--primary: 230 75% 55%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 230 75% 55%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 230 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 230 30% 94%;
|
||||
--sidebar-accent-foreground: 230 50% 20%;
|
||||
--sidebar-border: 230 20% 88%;
|
||||
--sidebar-ring: 230 75% 55%;
|
||||
}
|
||||
|
||||
.dark.midnight {
|
||||
--primary: 230 75% 62%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 230 75% 62%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 230 75% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 230 75% 62%;
|
||||
}
|
||||
|
||||
.rose {
|
||||
--primary: 346 77% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 346 77% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 346 77% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 346 30% 94%;
|
||||
--sidebar-accent-foreground: 346 50% 20%;
|
||||
--sidebar-border: 346 20% 88%;
|
||||
--sidebar-ring: 346 77% 50%;
|
||||
}
|
||||
|
||||
.dark.rose {
|
||||
--primary: 346 77% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 346 77% 58%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 346 77% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 346 77% 58%;
|
||||
}
|
||||
|
||||
.amber {
|
||||
--primary: 38 92% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 38 92% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 38 92% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 38 30% 94%;
|
||||
--sidebar-accent-foreground: 38 50% 20%;
|
||||
--sidebar-border: 38 20% 88%;
|
||||
--sidebar-ring: 38 92% 50%;
|
||||
}
|
||||
|
||||
.dark.amber {
|
||||
--primary: 38 92% 56%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 38 92% 56%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 38 92% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 38 92% 56%;
|
||||
}
|
||||
|
||||
.violet {
|
||||
--primary: 262 83% 58%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 262 83% 58%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 262 83% 58%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 262 30% 94%;
|
||||
--sidebar-accent-foreground: 262 50% 20%;
|
||||
--sidebar-border: 262 20% 88%;
|
||||
--sidebar-ring: 262 83% 58%;
|
||||
}
|
||||
|
||||
.dark.violet {
|
||||
--primary: 262 83% 65%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 262 83% 65%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 262 83% 68%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 262 83% 65%;
|
||||
}
|
||||
|
||||
.slate {
|
||||
--primary: 215 20% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 215 20% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 215 20% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 215 20% 94%;
|
||||
--sidebar-accent-foreground: 215 50% 20%;
|
||||
--sidebar-border: 215 20% 88%;
|
||||
--sidebar-ring: 215 20% 45%;
|
||||
}
|
||||
|
||||
.dark.slate {
|
||||
--primary: 215 20% 60%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 215 20% 60%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 215 20% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 215 20% 60%;
|
||||
}
|
||||
|
||||
.ruby {
|
||||
--primary: 351 85% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 351 85% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 351 85% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 351 30% 94%;
|
||||
--sidebar-accent-foreground: 351 50% 20%;
|
||||
--sidebar-border: 351 20% 88%;
|
||||
--sidebar-ring: 351 85% 45%;
|
||||
}
|
||||
|
||||
.dark.ruby {
|
||||
--primary: 351 85% 52%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 351 85% 52%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 351 85% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 351 85% 52%;
|
||||
}
|
||||
|
||||
.ocean {
|
||||
--primary: 187 75% 42%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 187 75% 42%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 187 75% 42%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 187 30% 94%;
|
||||
--sidebar-accent-foreground: 187 50% 20%;
|
||||
--sidebar-border: 187 20% 88%;
|
||||
--sidebar-ring: 187 75% 42%;
|
||||
}
|
||||
|
||||
.dark.ocean {
|
||||
--primary: 187 75% 50%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 187 75% 50%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 187 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 187 75% 50%;
|
||||
}
|
||||
|
||||
.forest {
|
||||
--primary: 142 76% 36%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 142 76% 36%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 142 76% 36%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 142 30% 94%;
|
||||
--sidebar-accent-foreground: 142 50% 20%;
|
||||
--sidebar-border: 142 20% 88%;
|
||||
--sidebar-ring: 142 76% 36%;
|
||||
}
|
||||
|
||||
.dark.forest {
|
||||
--primary: 142 76% 44%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 142 76% 44%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 142 76% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 142 76% 44%;
|
||||
}
|
||||
|
||||
.sunset {
|
||||
--primary: 24 95% 53%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 24 95% 53%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 24 95% 53%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 24 30% 94%;
|
||||
--sidebar-accent-foreground: 24 50% 20%;
|
||||
--sidebar-border: 24 20% 88%;
|
||||
--sidebar-ring: 24 95% 53%;
|
||||
}
|
||||
|
||||
.dark.sunset {
|
||||
--primary: 24 95% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 24 95% 58%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 24 95% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 24 95% 58%;
|
||||
}
|
||||
|
||||
.midnight {
|
||||
--primary: 230 75% 55%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 230 75% 55%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 230 75% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 230 30% 94%;
|
||||
--sidebar-accent-foreground: 230 50% 20%;
|
||||
--sidebar-border: 230 20% 88%;
|
||||
--sidebar-ring: 230 75% 55%;
|
||||
}
|
||||
|
||||
.dark.midnight {
|
||||
--primary: 230 75% 62%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 230 75% 62%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 230 75% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 230 75% 62%;
|
||||
}
|
||||
|
||||
.rose {
|
||||
--primary: 346 77% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 346 77% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 346 77% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 346 30% 94%;
|
||||
--sidebar-accent-foreground: 346 50% 20%;
|
||||
--sidebar-border: 346 20% 88%;
|
||||
--sidebar-ring: 346 77% 50%;
|
||||
}
|
||||
|
||||
.dark.rose {
|
||||
--primary: 346 77% 58%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 346 77% 58%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 346 77% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 346 77% 58%;
|
||||
}
|
||||
|
||||
.amber {
|
||||
--primary: 38 92% 50%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 38 92% 50%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 38 92% 50%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 38 30% 94%;
|
||||
--sidebar-accent-foreground: 38 50% 20%;
|
||||
--sidebar-border: 38 20% 88%;
|
||||
--sidebar-ring: 38 92% 50%;
|
||||
}
|
||||
|
||||
.dark.amber {
|
||||
--primary: 38 92% 56%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 38 92% 56%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 38 92% 60%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 38 92% 56%;
|
||||
}
|
||||
|
||||
.violet {
|
||||
--primary: 262 83% 58%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 262 83% 58%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 262 83% 58%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 262 30% 94%;
|
||||
--sidebar-accent-foreground: 262 50% 20%;
|
||||
--sidebar-border: 262 20% 88%;
|
||||
--sidebar-ring: 262 83% 58%;
|
||||
}
|
||||
|
||||
.dark.violet {
|
||||
--primary: 262 83% 65%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 262 83% 65%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 262 83% 68%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 262 83% 65%;
|
||||
}
|
||||
|
||||
.slate {
|
||||
--primary: 215 20% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 215 20% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 215 20% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 215 20% 94%;
|
||||
--sidebar-accent-foreground: 215 50% 20%;
|
||||
--sidebar-border: 215 20% 88%;
|
||||
--sidebar-ring: 215 20% 45%;
|
||||
}
|
||||
|
||||
.dark.slate {
|
||||
--primary: 215 20% 60%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 215 20% 60%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 215 20% 65%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 215 20% 60%;
|
||||
}
|
||||
|
||||
.ruby {
|
||||
--primary: 351 85% 45%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--ring: 351 85% 45%;
|
||||
--sidebar: 0 0% 100%;
|
||||
--sidebar-foreground: 222.2 84% 4.9%;
|
||||
--sidebar-primary: 351 85% 45%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 351 30% 94%;
|
||||
--sidebar-accent-foreground: 351 50% 20%;
|
||||
--sidebar-border: 351 20% 88%;
|
||||
--sidebar-ring: 351 85% 45%;
|
||||
}
|
||||
|
||||
.dark.ruby {
|
||||
--primary: 351 85% 52%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--ring: 351 85% 52%;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 351 85% 55%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 217.2 32.6% 17.5%;
|
||||
--sidebar-ring: 351 85% 52%;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login page custom styles */
|
||||
.left-panel {
|
||||
background: #0a0a0f;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.right-panel {
|
||||
background: #111118;
|
||||
width: 420px;
|
||||
flex: none;
|
||||
position: relative;
|
||||
}
|
||||
.panel-divider {
|
||||
width: 1px;
|
||||
background: rgba(180, 192, 210, 0.1);
|
||||
flex: none;
|
||||
}
|
||||
.growth-word {
|
||||
position: relative;
|
||||
color: #1BB0CE;
|
||||
}
|
||||
.growth-word::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: #1BB0CE;
|
||||
animation: pulseUnderline 2.5s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseUnderline {
|
||||
0%, 100% { background-color: #1BB0CE; }
|
||||
50% { background-color: rgba(180, 192, 210, 0.6); }
|
||||
}
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 32px;
|
||||
gap: 0;
|
||||
}
|
||||
.stat {
|
||||
flex: 1;
|
||||
max-width: 120px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.stat::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, #1BB0CE, rgba(180,192,210,1), #1BB0CE);
|
||||
background-size: 200% 100%;
|
||||
animation: statSweep 2.5s ease-in-out infinite;
|
||||
}
|
||||
.stat:nth-child(2)::before {
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
.stat:nth-child(3)::before {
|
||||
animation-delay: 1.2s;
|
||||
}
|
||||
@keyframes statSweep {
|
||||
0% { background-position: 0% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.stat-number {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1BB0CE;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: rgba(232,232,239,0.3);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.testimonial {
|
||||
border-left: 2px solid rgba(27,176,206,0.25);
|
||||
background: rgba(27,176,206,0.06);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.stars-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
.wave-canvas {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
background: #0a0a0f;
|
||||
}
|
||||
.accent-line {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #1BB0CE, rgba(232,232,239,0.15), transparent);
|
||||
animation: pulseAccent 3s ease-in-out infinite;
|
||||
}
|
||||
@keyframes pulseAccent {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.input-sheen {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.input-sheen::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -10%;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 120%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
|
||||
transform: rotate(-15deg);
|
||||
animation: sheenFloat 3.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.input-sheen-delayed::before {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
.input-sheen:focus-within::before {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes sheenFloat {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
background: linear-gradient(135deg, #0d0d15, #151520, #0d0d15);
|
||||
background-size: 200% 200%;
|
||||
animation: bgShift 6s ease-in-out infinite;
|
||||
border: 1.5px solid rgba(180,192,210,0.15);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #e8e8ef;
|
||||
padding: 0 12px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.login-input::placeholder {
|
||||
color: rgba(232,232,239,0.2);
|
||||
}
|
||||
.login-input:focus {
|
||||
border-color: #1BB0CE;
|
||||
outline: none;
|
||||
background: #111118;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes bgShift {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(232,232,239,0.3);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
z-index: 3;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: rgba(232,232,239,0.5);
|
||||
}
|
||||
.login-checkbox {
|
||||
accent-color: #1BB0CE;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.auth-btn {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: #1BB0CE;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 13px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.auth-btn:hover {
|
||||
background: #17a0bc;
|
||||
}
|
||||
.auth-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.auth-btn::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||
animation: btnSweep 2.2s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes btnSweep {
|
||||
0% { left: -100%; }
|
||||
100% { left: 200%; }
|
||||
}
|
||||
.login-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: rgba(232,232,239,0.4);
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.body-text { color: rgba(232,232,239,0.5); }
|
||||
.subheading-text { color: rgba(232,232,239,0.38); }
|
||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||
.footer-text { color: rgba(232,232,239,0.2); }
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { Inter } from "next/font/google"
|
||||
import { ThemeProvider } from "@/providers/theme-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
})
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CRM",
|
||||
description: "Customer Relationship Management System",
|
||||
icons: {
|
||||
icon: "/logo/CompanyMiniLogo.png",
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export default function LoginLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { COMPANY_NAME } from "@/lib/constants"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
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)" },
|
||||
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
|
||||
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
|
||||
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [email, setEmail] = useState("")
|
||||
const [password, setPassword] = useState("")
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
|
||||
const counterStarted = useRef(false)
|
||||
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
||||
author: "Marcus Johnson, Sales Lead at Coast IT",
|
||||
},
|
||||
{
|
||||
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
||||
author: "Dillen van der Merwe, Madman of Coast IT",
|
||||
},
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
||||
}, 5000)
|
||||
return () => clearInterval(timer)
|
||||
}, [testimonials.length])
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
const parent = canvas.parentElement!
|
||||
const rect = parent.getBoundingClientRect()
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = rect.width * dpr
|
||||
canvas.height = 200 * dpr
|
||||
canvas.style.width = rect.width + "px"
|
||||
canvas.style.height = "200px"
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
|
||||
resize()
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
const w = canvas.width / (window.devicePixelRatio || 1)
|
||||
const h = 200
|
||||
ctx!.clearRect(0, 0, w, h)
|
||||
|
||||
for (const wave of waves) {
|
||||
ctx!.beginPath()
|
||||
ctx!.moveTo(0, h)
|
||||
for (let x = 0; x <= w; x++) {
|
||||
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
|
||||
ctx!.lineTo(x, y)
|
||||
}
|
||||
ctx!.lineTo(w, h)
|
||||
ctx!.closePath()
|
||||
ctx!.fillStyle = wave.fill
|
||||
ctx!.fill()
|
||||
}
|
||||
|
||||
time += 1
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
||||
if (canvases.length === 0) return
|
||||
|
||||
const stars = Array.from({ length: 312 }, () => ({
|
||||
x: Math.random(),
|
||||
y: Math.random(),
|
||||
r: 0.2 + Math.random() * 0.6,
|
||||
baseOpacity: 0.12 + Math.random() * 0.43,
|
||||
speed: 0.003 + Math.random() * 0.015,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
driftX: (Math.random() - 0.5) * 0.00003,
|
||||
driftY: (Math.random() - 0.5) * 0.00003,
|
||||
}))
|
||||
|
||||
let animId: number
|
||||
let time = 0
|
||||
|
||||
const resize = () => {
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const rect = c.getBoundingClientRect()
|
||||
c.width = rect.width * dpr
|
||||
c.height = rect.height * dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
resize()
|
||||
const ros = canvases.map((c) => {
|
||||
const ro = new ResizeObserver(() => resize())
|
||||
ro.observe(c.parentElement!)
|
||||
return ro
|
||||
})
|
||||
window.addEventListener("resize", resize)
|
||||
|
||||
const draw = () => {
|
||||
time += 1
|
||||
for (const c of canvases) {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
const w = c.width / dpr
|
||||
const h = c.height / dpr
|
||||
const ctx = c.getContext("2d")
|
||||
if (!ctx) continue
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
|
||||
for (const star of stars) {
|
||||
const x = ((star.x + time * star.driftX) % 1) * w
|
||||
const y = ((star.y + time * star.driftY) % 1) * h
|
||||
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
||||
ctx.fill()
|
||||
|
||||
if (star.r > 0.5) {
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(draw)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animId)
|
||||
ros.forEach((ro) => ro.disconnect())
|
||||
window.removeEventListener("resize", resize)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (counterStarted.current) return
|
||||
counterStarted.current = true
|
||||
const targets = { leads: 1247, conversion: 40, users: 83 }
|
||||
const steps = 150
|
||||
|
||||
const delayTimer = setTimeout(() => {
|
||||
let step = 0
|
||||
const interval = setInterval(() => {
|
||||
step++
|
||||
if (step >= steps) {
|
||||
clearInterval(interval)
|
||||
setCounts(targets)
|
||||
return
|
||||
}
|
||||
setCounts({
|
||||
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
||||
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
||||
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
|
||||
})
|
||||
}, 15)
|
||||
}, 400)
|
||||
|
||||
return () => clearTimeout(delayTimer)
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError("")
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
router.push("/dashboard")
|
||||
} else {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
setError(data.error || "Invalid email or password.")
|
||||
}
|
||||
} catch {
|
||||
setError("Connection error. Please try again.")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-[#0a0a0f]">
|
||||
<div className="left-panel flex flex-1 flex-col p-12">
|
||||
<div className="relative z-10">
|
||||
<img
|
||||
src="/logo/CompanyLogo.png"
|
||||
alt={COMPANY_NAME}
|
||||
className="h-44 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
||||
Your Agency's{" "}
|
||||
<span className="growth-word">Growth</span>{" "}
|
||||
Starts Here
|
||||
</h1>
|
||||
<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>
|
||||
<div className="stats-row">
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
||||
<div className="stat-label">leads tracked</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.conversion}%</div>
|
||||
<div className="stat-label">conversion lift</div>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<div className="stat-number">{counts.users}</div>
|
||||
<div className="stat-label">active users</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
|
||||
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
||||
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
||||
“{testimonials[testimonialIndex].text}”
|
||||
</p>
|
||||
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
|
||||
{testimonials[testimonialIndex].author}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-1.5 mt-3">
|
||||
{testimonials.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => setTestimonialIndex(i)}
|
||||
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas ref={canvasRef} className="wave-canvas" />
|
||||
</div>
|
||||
|
||||
<div className="panel-divider" />
|
||||
|
||||
<div className="right-panel flex items-center justify-center p-10">
|
||||
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
||||
<div className="accent-line" />
|
||||
|
||||
<div className="w-full" style={{ maxWidth: 340 }}>
|
||||
<h2 className="text-[24px] font-bold text-[#e8e8ef] tracking-[-0.3px] mb-[5px]">
|
||||
Welcome back
|
||||
</h2>
|
||||
<p className="text-[13px] mb-[26px] subheading-text">
|
||||
Sign in to your account to continue
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label htmlFor="email" className="login-label">
|
||||
Email
|
||||
</label>
|
||||
<div className="input-sheen">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoComplete="email"
|
||||
className="login-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-[6px]">
|
||||
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
||||
Password
|
||||
</label>
|
||||
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<div className="input-sheen input-sheen-delayed">
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="login-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="password-toggle"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="login-checkbox"
|
||||
/>
|
||||
<span className="text-[13px] checkbox-text">
|
||||
Remember me
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="auth-btn" disabled={loading}>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<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">
|
||||
Terms of Service
|
||||
</button>{" "}
|
||||
and{" "}
|
||||
<button type="button" className="text-[#1BB0CE] hover:underline">
|
||||
Privacy Policy
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FileQuestion } from "lucide-react"
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col items-center text-center"
|
||||
>
|
||||
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-muted">
|
||||
<FileQuestion className="h-12 w-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-lg text-muted-foreground">Page not found</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
<Link href="/dashboard" className="mt-6">
|
||||
<Button>Go Home</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function RootPage() {
|
||||
redirect("/dashboard")
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, Fragment } from "react"
|
||||
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react"
|
||||
|
||||
function linkifyText(text: string) {
|
||||
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
|
||||
const parts = text.split(urlRegex)
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("http://") || part.startsWith("https://")) {
|
||||
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
|
||||
}
|
||||
return <Fragment key={i}>{part}</Fragment>
|
||||
})
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
}
|
||||
|
||||
export function AIChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${AI_API}/ai/jobs`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data.jobs?.length) {
|
||||
const names = data.jobs.map((j: { job_title: string }) => j.job_title)
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: `Hi! I'm your Sales AI Assistant. I can help with tips for targeting:\n\n${names.map((n: string) => `• ${n}`).join("\n")}\n\nWhat would you like to know?`,
|
||||
},
|
||||
])
|
||||
} else {
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Hi! I'm your Sales AI Assistant. Ask me anything about sales strategies and prospect targeting.",
|
||||
},
|
||||
])
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const AI_API = process.env.NEXT_PUBLIC_AI_URL || "http://127.0.0.1:3001"
|
||||
|
||||
const checkOllama = async () => {
|
||||
try {
|
||||
const res = await fetch(`${AI_API}/health`)
|
||||
const data = await res.json()
|
||||
setOllamaStatus(data.status === "ok")
|
||||
} catch {
|
||||
setOllamaStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { checkOllama() }, [])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const msg = input.trim()
|
||||
if (!msg || loading) return
|
||||
|
||||
setInput("")
|
||||
setError("")
|
||||
setMessages((prev) => [...prev, { role: "user", content: msg }])
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${AI_API}/ai/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: msg }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || `Error ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setMessages((prev) => [...prev, { role: "assistant", content: data.response }])
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : "AI service unavailable"
|
||||
setError(errMsg)
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{ role: "assistant", content: `⚠️ Error: ${errMsg}. Make sure Ollama is running with the model loaded.` },
|
||||
])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{ollamaStatus === false && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs">
|
||||
<AlertCircle className="h-3.5 w-3.5 flex-none" />
|
||||
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span>
|
||||
<button type="button" onClick={checkOllama} className="hover:text-amber-300">
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
{msg.role === "assistant" && (
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
||||
msg.role === "user"
|
||||
? "bg-[#1BB0CE] text-white"
|
||||
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
|
||||
}`}
|
||||
>
|
||||
{linkifyText(msg.content)}
|
||||
</div>
|
||||
{msg.role === "user" && (
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
||||
</div>
|
||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#2a2a35] p-4">
|
||||
{error && (
|
||||
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask for sales tips..."
|
||||
rows={1}
|
||||
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={sendMessage}
|
||||
disabled={loading || !input.trim()}
|
||||
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
|
||||
>
|
||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
|
||||
|
||||
interface Job {
|
||||
job_title: string
|
||||
keywords: string[]
|
||||
industry: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface JobSelectorProps {
|
||||
onSelect: (job: Job | null) => void
|
||||
}
|
||||
|
||||
export function JobSelector({ onSelect }: JobSelectorProps) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Job | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setJobs(data.jobs || []))
|
||||
.catch(() => setJobs([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleSelect = (job: Job) => {
|
||||
setSelected(job)
|
||||
setOpen(false)
|
||||
onSelect(job)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
|
||||
>
|
||||
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
|
||||
<span className="flex-1 text-left truncate">
|
||||
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
|
||||
</span>
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
|
||||
{jobs.map((job, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => handleSelect(job)}
|
||||
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
|
||||
>
|
||||
<div className="font-medium">{job.job_title}</div>
|
||||
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} — {job.description}</div>
|
||||
</button>
|
||||
))}
|
||||
{jobs.length === 0 && !loading && (
|
||||
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
interface StatusData {
|
||||
name: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface LeadStatusChartProps {
|
||||
data: StatusData[]
|
||||
}
|
||||
|
||||
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) }
|
||||
}
|
||||
|
||||
function arcPath(cx: number, cy: number, oR: number, iR: number, start: number, end: number) {
|
||||
const gap = 3
|
||||
const s = start + gap / 2
|
||||
const e = end - gap / 2
|
||||
const o1 = polar(cx, cy, oR, s)
|
||||
const o2 = polar(cx, cy, oR, e)
|
||||
const i1 = polar(cx, cy, iR, e)
|
||||
const i2 = polar(cx, cy, iR, s)
|
||||
const lg = e - s > 180 ? 1 : 0
|
||||
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`
|
||||
}
|
||||
|
||||
export function LeadStatusChart({ data }: LeadStatusChartProps) {
|
||||
const [hov, setHov] = useState<number | null>(null)
|
||||
|
||||
const total = data.reduce((s, d) => s + d.value, 0)
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
No data for this period
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
let cum = 0
|
||||
const segs = data.map((d) => {
|
||||
const start = cum
|
||||
const deg = (d.value / total) * 360
|
||||
cum += deg
|
||||
return { ...d, start, end: cum, deg, pct: Math.round((d.value / total) * 100) }
|
||||
})
|
||||
|
||||
const active = hov !== null ? segs[hov] : null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.2 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full relative overflow-hidden">
|
||||
{/* Spider watermark */}
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.05]">
|
||||
<svg width="220" height="220" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="90" y1="0" x2="90" y2="180" stroke="#CC0000" strokeWidth="1" />
|
||||
<line x1="0" y1="90" x2="180" y2="90" stroke="#CC0000" strokeWidth="1" />
|
||||
<line x1="30" y1="30" x2="150" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||
<line x1="150" y1="30" x2="30" y2="150" stroke="#0033CC" strokeWidth="1" />
|
||||
<circle cx="90" cy="90" r="40" stroke="#CC0000" strokeWidth="1" fill="none" />
|
||||
<circle cx="90" cy="90" r="60" stroke="#0033CC" strokeWidth="0.8" fill="none" strokeDasharray="4 4" />
|
||||
<circle cx="90" cy="90" r="80" stroke="#CC0000" strokeWidth="0.5" fill="none" strokeDasharray="2 6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<CardHeader>
|
||||
<CardTitle>Lead Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col relative z-10">
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
{/* Donut */}
|
||||
<svg width="100%" height="100%" viewBox="0 0 320 320" className="max-h-full overflow-visible" style={{ maxWidth: "280px" }}>
|
||||
<defs>
|
||||
{segs.map((s, i) => (
|
||||
<radialGradient key={i} id={`chart-grad-${i}`} cx="50%" cy="50%" r="50%">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity="1" />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity="0.75" />
|
||||
</radialGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
{/* Background ring */}
|
||||
<circle cx="160" cy="160" r="105" fill="none" stroke="hsl(var(--muted))" strokeWidth="52" />
|
||||
|
||||
{/* Segments */}
|
||||
{segs.map((s, i) => {
|
||||
const isH = hov === i
|
||||
const oR = isH ? 137 : 130
|
||||
const iR = isH ? 73 : 80
|
||||
return (
|
||||
<path
|
||||
key={i}
|
||||
d={arcPath(160, 160, oR, iR, s.start, s.end)}
|
||||
fill={`url(#chart-grad-${i})`}
|
||||
opacity={hov !== null && !isH ? 0.3 : 1}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "all 0.22s cubic-bezier(.4,0,.2,1)",
|
||||
filter: isH ? `drop-shadow(0 0 10px ${s.color}99)` : "none",
|
||||
}}
|
||||
onMouseEnter={() => setHov(i)}
|
||||
onMouseLeave={() => setHov(null)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Center circle */}
|
||||
<circle cx="160" cy="160" r="66" fill="hsl(var(--card))" />
|
||||
<circle cx="160" cy="160" r="66" fill="none" stroke="hsl(var(--border))" strokeWidth="1" />
|
||||
|
||||
{/* Center text */}
|
||||
{active ? (
|
||||
<>
|
||||
<circle cx="160" cy="160" r="66" fill={active.color + "15"} />
|
||||
<text x="160" y="148" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="30" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||
{active.value}
|
||||
</text>
|
||||
<text x="160" y="166" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||
{active.name}
|
||||
</text>
|
||||
<text x="160" y="184" textAnchor="middle" fill={active.color} fontSize="13" fontWeight="600" fontFamily="-apple-system,sans-serif">
|
||||
{active.pct}%
|
||||
</text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<text x="160" y="152" textAnchor="middle" fill="hsl(var(--foreground))" fontSize="34" fontWeight="700" fontFamily="-apple-system,sans-serif">
|
||||
{total}
|
||||
</text>
|
||||
<text x="160" y="172" textAnchor="middle" fill="hsl(var(--muted-foreground))" fontSize="12" fontFamily="-apple-system,sans-serif">
|
||||
Total Leads
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-6 grid w-full grid-cols-2 gap-2">
|
||||
{segs.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onMouseEnter={() => setHov(i)}
|
||||
onMouseLeave={() => setHov(null)}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-xl p-2.5 transition-all duration-200"
|
||||
style={{
|
||||
background: hov === i ? `${s.color}18` : "hsl(var(--muted) / 0.3)",
|
||||
border: `1px solid ${hov === i ? s.color + "50" : "hsl(var(--border))"}`,
|
||||
gridColumn: i === segs.length - 1 && segs.length % 2 !== 0 ? "1 / -1" : undefined,
|
||||
maxWidth: i === segs.length - 1 && segs.length % 2 !== 0 ? "50%" : undefined,
|
||||
margin: i === segs.length - 1 && segs.length % 2 !== 0 ? "0 auto" : undefined,
|
||||
width: i === segs.length - 1 && segs.length % 2 !== 0 ? "100%" : undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-full transition-shadow duration-200"
|
||||
style={{
|
||||
background: s.color,
|
||||
boxShadow: hov === i ? `0 0 8px ${s.color}` : "none",
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className="text-xs font-medium transition-colors duration-200"
|
||||
style={{ color: hov === i ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{s.name}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px]" style={{ color: "hsl(var(--muted-foreground) / 0.7)" }}>
|
||||
{s.value} · {s.pct}%
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs font-semibold transition-colors duration-200"
|
||||
style={{ color: hov === i ? s.color : "hsl(var(--muted-foreground) / 0.5)" }}
|
||||
>
|
||||
{s.pct}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useMemo, useEffect } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
|
||||
interface IntervalData {
|
||||
label: string
|
||||
leads: number
|
||||
closed: number
|
||||
}
|
||||
|
||||
interface LeadsPerMonthChartProps {
|
||||
data: IntervalData[]
|
||||
}
|
||||
|
||||
const NEW_LEADS = "#CC0000"
|
||||
const CLOSED = "#0033CC"
|
||||
|
||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [chartData, setChartData] = useState<IntervalData[]>(initialData)
|
||||
const [hovered, setHovered] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setChartData(initialData)
|
||||
}, [initialData])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchYearData() {
|
||||
try {
|
||||
const res = await fetch(`/api/dashboard?year=${year}`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setChartData(data.leadsPerMonth || [])
|
||||
}
|
||||
} catch {
|
||||
console.warn("Failed to fetch year data in leads per month chart")
|
||||
}
|
||||
}
|
||||
const thisYear = new Date().getFullYear()
|
||||
if (year !== thisYear || initialData.length === 0) {
|
||||
fetchYearData()
|
||||
} else {
|
||||
setChartData(initialData)
|
||||
}
|
||||
}, [year, initialData])
|
||||
|
||||
const width = 880
|
||||
const height = 620
|
||||
const padding = { top: 128, right: 24, bottom: 48, left: 44 }
|
||||
const chartW = width - padding.left - padding.right
|
||||
const chartH = height - padding.top - padding.bottom
|
||||
|
||||
const maxVal = useMemo(() => {
|
||||
if (chartData.length === 0) return 1
|
||||
const max = Math.max(...chartData.map((d) => Math.max(d.leads, d.closed)))
|
||||
return Math.max(Math.ceil(max / 7) * 7, 1)
|
||||
}, [chartData])
|
||||
|
||||
const ticks = useMemo(() => {
|
||||
const steps = 4
|
||||
return Array.from({ length: steps + 1 }, (_, i) => Math.round((maxVal / steps) * i))
|
||||
}, [maxVal])
|
||||
|
||||
const groupW = chartW / Math.max(chartData.length, 1)
|
||||
const barW = groupW * 0.28
|
||||
const gap = groupW * 0.06
|
||||
|
||||
const yScale = (v: number) => chartH - (v / maxVal) * chartH
|
||||
|
||||
const active = hovered
|
||||
const activeDatum = active !== null ? chartData[active] : null
|
||||
|
||||
const totalNew = chartData.reduce((s, d) => s + d.leads, 0)
|
||||
const totalClosed = chartData.reduce((s, d) => s + d.closed, 0)
|
||||
const closeRate = totalNew > 0 ? Math.round((totalClosed / totalNew) * 100) : 0
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="min-w-[60px] text-center text-sm font-medium">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-[400px] text-sm text-muted-foreground">
|
||||
No data for {year}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.3 }}
|
||||
className="h-full"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle>Leads Per Month</CardTitle>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{totalNew} new · {totalClosed} closed · {closeRate}% close rate
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||
<span className="text-xs text-muted-foreground">New Leads</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: CLOSED }} />
|
||||
<span className="text-xs text-muted-foreground">Closed</span>
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-1 rounded-lg border px-2 py-1">
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => y - 1)}>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<span className="min-w-[50px] text-center text-sm font-semibold">{year}</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => setYear((y) => Math.min(y + 1, currentYear))} disabled={year >= currentYear}>
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-1 flex-col">
|
||||
<div className="relative flex-1">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
className="block overflow-visible"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FF1111" />
|
||||
<stop offset="100%" stopColor={NEW_LEADS} />
|
||||
</linearGradient>
|
||||
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#1144FF" />
|
||||
<stop offset="100%" stopColor={CLOSED} />
|
||||
</linearGradient>
|
||||
<filter id="shadowNew">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={NEW_LEADS} floodOpacity="0.4" />
|
||||
</filter>
|
||||
<filter id="shadowClosed">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="4" floodColor={CLOSED} floodOpacity="0.4" />
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<g transform={`translate(${padding.left}, ${padding.top})`}>
|
||||
{/* Grid lines */}
|
||||
{ticks.map((t, i) => (
|
||||
<g key={i}>
|
||||
<line
|
||||
x1={0}
|
||||
x2={chartW}
|
||||
y1={yScale(t)}
|
||||
y2={yScale(t)}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeDasharray={t === 0 ? "0" : "3 5"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={-12}
|
||||
y={yScale(t)}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
fontSize={12}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Bars */}
|
||||
{chartData.map((d, i) => {
|
||||
const groupX = i * groupW
|
||||
const isActive = active === i
|
||||
const newH = chartH - yScale(d.leads)
|
||||
const closedH = chartH - yScale(d.closed)
|
||||
|
||||
return (
|
||||
<g
|
||||
key={d.label}
|
||||
onMouseEnter={() => setHovered(i)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<rect
|
||||
x={groupX}
|
||||
y={0}
|
||||
width={groupW}
|
||||
height={chartH}
|
||||
fill={isActive ? "hsl(var(--muted) / 0.5)" : "transparent"}
|
||||
rx={6}
|
||||
/>
|
||||
|
||||
<rect
|
||||
x={groupX + groupW / 2 - barW - gap / 2}
|
||||
y={yScale(d.leads)}
|
||||
width={barW}
|
||||
height={newH}
|
||||
rx={4}
|
||||
fill="url(#newLeadsGrad)"
|
||||
filter={isActive ? "url(#shadowNew)" : undefined}
|
||||
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
<rect
|
||||
x={groupX + groupW / 2 + gap / 2}
|
||||
y={yScale(d.closed)}
|
||||
width={barW}
|
||||
height={closedH}
|
||||
rx={4}
|
||||
fill="url(#closedGrad)"
|
||||
filter={isActive ? "url(#shadowClosed)" : undefined}
|
||||
opacity={hovered !== null && !isActive ? 0.35 : 1}
|
||||
style={{ transition: "opacity 0.15s ease" }}
|
||||
/>
|
||||
|
||||
<text
|
||||
x={groupX + groupW / 2}
|
||||
y={chartH + 26}
|
||||
fill={isActive ? "hsl(var(--foreground))" : "hsl(var(--muted-foreground))"}
|
||||
fontSize={12.5}
|
||||
fontWeight={isActive ? 600 : 400}
|
||||
textAnchor="middle"
|
||||
>
|
||||
{d.label}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{activeDatum && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0"
|
||||
style={{
|
||||
left: `${((active! + 0.5) / chartData.length) * 100}%`,
|
||||
transform: active! > chartData.length - 2
|
||||
? "translate(-100%, 0)"
|
||||
: "translate(-12%, 0)",
|
||||
transition: "left 0.15s ease",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="min-w-[150px] rounded-xl border p-3 shadow-xl"
|
||||
style={{
|
||||
background: "hsl(var(--popover))",
|
||||
borderColor: "hsl(var(--border))",
|
||||
}}
|
||||
>
|
||||
<div className="mb-1.5 text-xs font-semibold" style={{ color: "hsl(var(--foreground))" }}>
|
||||
{activeDatum.label}
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: NEW_LEADS }} />
|
||||
New Leads
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.leads}</span>
|
||||
</div>
|
||||
<div className="mb-1 flex items-center justify-between gap-4 text-xs" style={{ color: "hsl(var(--muted-foreground))" }}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-2 rounded-sm" style={{ background: CLOSED }} />
|
||||
Closed
|
||||
</span>
|
||||
<span className="font-semibold" style={{ color: "hsl(var(--foreground))" }}>{activeDatum.closed}</span>
|
||||
</div>
|
||||
<div
|
||||
className="mt-1.5 border-t pt-1.5 text-[11px]"
|
||||
style={{ borderColor: "hsl(var(--border))", color: "hsl(var(--muted-foreground))" }}
|
||||
>
|
||||
{activeDatum.leads > 0
|
||||
? `${Math.round((activeDatum.closed / activeDatum.leads) * 100)}% close rate`
|
||||
: "No leads"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { motion } from "framer-motion"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Lead } from "@/types"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
|
||||
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",
|
||||
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
|
||||
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
|
||||
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
|
||||
}
|
||||
|
||||
interface RecentLeadsTableProps {
|
||||
leads: Lead[]
|
||||
}
|
||||
|
||||
export function RecentLeadsTable({ leads }: RecentLeadsTableProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.4 }}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Recent Leads</CardTitle>
|
||||
<Link
|
||||
href="/leads"
|
||||
className="flex items-center gap-1 text-sm text-primary hover:underline"
|
||||
>
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-3 font-medium">Company</th>
|
||||
<th className="pb-3 font-medium">Contact</th>
|
||||
<th className="hidden pb-3 font-medium md:table-cell">Status</th>
|
||||
<th className="hidden pb-3 font-medium lg:table-cell">Assigned</th>
|
||||
<th className="hidden pb-3 font-medium sm:table-cell">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{leads.map((lead, i) => (
|
||||
<motion.tr
|
||||
key={lead.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: i * 0.03 }}
|
||||
className="border-b last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td className="py-3">
|
||||
<Link href={`/leads/${lead.id}`} className="font-medium hover:text-primary">
|
||||
{lead.companyName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 text-muted-foreground">{lead.contactName}</td>
|
||||
<td className="hidden py-3 md:table-cell">
|
||||
<Badge variant="outline" className={statusStyles[lead.status]}>
|
||||
{lead.status.charAt(0).toUpperCase() + lead.status.slice(1)}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="hidden py-3 lg:table-cell">
|
||||
{lead.assignedUser ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={lead.assignedUser.avatar} />
|
||||
<AvatarFallback>{lead.assignedUser.name[0]}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-muted-foreground">{lead.assignedUser.name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/50">Unassigned</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden py-3 text-muted-foreground sm:table-cell">
|
||||
{new Date(lead.createdAt).toLocaleDateString()}
|
||||
</td>
|
||||
</motion.tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
"use client"
|
||||
|
||||
export function StatCardSkeleton() {
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
|
||||
THWIP!
|
||||
</p>
|
||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-3">
|
||||
Loading your data...
|
||||
</p>
|
||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
description?: string
|
||||
index?: number
|
||||
trend?: { pct: number; up: boolean }
|
||||
sparklineField?: "total" | "open" | "contacted" | "pending" | "closed" | "ignored"
|
||||
monthlyBreakdown?: { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }[]
|
||||
conversionRate?: number
|
||||
}
|
||||
|
||||
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
|
||||
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
|
||||
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
|
||||
}
|
||||
|
||||
function computeGoal(max: number): number {
|
||||
if (max <= 0) return 100
|
||||
return Math.ceil(max / 100) * 100 || 100
|
||||
}
|
||||
|
||||
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)}`
|
||||
let d = `M${points[0].x.toFixed(1)},${points[0].y.toFixed(1)}`
|
||||
for (let i = 1; i < points.length - 1; i++) {
|
||||
const p0 = points[i - 1]
|
||||
const p1 = points[i]
|
||||
const p2 = points[i + 1]
|
||||
const cp1x = p1.x - (p2.x - p0.x) * 0.15
|
||||
const cp1y = p1.y - (p2.y - p0.y) * 0.15
|
||||
const cp2x = p1.x + (p2.x - p0.x) * 0.15
|
||||
const cp2y = p1.y + (p2.y - p0.y) * 0.15
|
||||
d += `C${cp1x.toFixed(1)},${cp1y.toFixed(1)} ${cp2x.toFixed(1)},${cp2y.toFixed(1)} ${p2.x.toFixed(1)},${p2.y.toFixed(1)}`
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||
|
||||
const isNumeric = typeof value === "number"
|
||||
const [display, setDisplay] = useState(0)
|
||||
const target = typeof value === "number" ? value : 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNumeric) return
|
||||
const duration = 1000
|
||||
const steps = 40
|
||||
const increment = target / steps
|
||||
let current = 0
|
||||
const timer = setInterval(() => {
|
||||
current += increment
|
||||
if (current >= target) {
|
||||
setDisplay(target)
|
||||
clearInterval(timer)
|
||||
} else {
|
||||
setDisplay(Math.floor(current))
|
||||
}
|
||||
}, duration / steps)
|
||||
return () => clearInterval(timer)
|
||||
}, [target, isNumeric])
|
||||
|
||||
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()
|
||||
const max = Math.max(...values, 0)
|
||||
const goal = computeGoal(max)
|
||||
const dataRange = Math.max(max, 1)
|
||||
const range = goal <= 10 ? Math.max(dataRange * 2, 10) : Math.min(goal, Math.max(dataRange * 3, 10))
|
||||
const w = values.length - 1 || 1
|
||||
const pad = 4
|
||||
const graphW = 60 - pad * 2
|
||||
const graphH = 28
|
||||
|
||||
const points = values.map((v, i) => ({
|
||||
x: pad + (i / w) * graphW,
|
||||
y: graphH - (v / range) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
const smooth = smoothPath(points)
|
||||
const area = points.length > 0
|
||||
? `${smooth} L${points[points.length - 1].x.toFixed(1)},${graphH} L${points[0].x.toFixed(1)},${graphH} Z`
|
||||
: ""
|
||||
|
||||
const steps = 4
|
||||
const gridLines = Array.from({ length: steps - 1 }, (_, i) => ({
|
||||
y: graphH - ((i + 1) / steps) * (graphH - 3) - 1,
|
||||
}))
|
||||
|
||||
return { path: smooth, area, goal, gridLines }
|
||||
}
|
||||
|
||||
const { path: sparkPath, area: sparkArea, goal, gridLines } = buildSparklineSvg()
|
||||
const sparkColor = color.accent
|
||||
|
||||
const isRed = index % 2 === 0
|
||||
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
|
||||
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: index * 0.05 }}
|
||||
className="relative"
|
||||
>
|
||||
{/* Web overlay decorative */}
|
||||
<div className="absolute inset-0 pointer-events-none z-0 opacity-[0.03] dark:opacity-[0.06]">
|
||||
<svg width="100%" height="100%" viewBox="0 0 180 180" preserveAspectRatio="none" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wl-${i}`} x1="0" y1={i * 36} x2="180" y2={i * 36} stroke="#CC0000" strokeWidth="0.5" />
|
||||
))}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wc-${i}`} x1={i * 36} y1="0" x2={i * 36} y2="180" stroke="#CC0000" strokeWidth="0.5" />
|
||||
))}
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<line key={`wd-${i}`} x1="0" y1={i * 36} x2={180 - i * 36} y2="180" stroke="#0033CC" strokeWidth="0.5" />
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Comic action lines on hover */}
|
||||
<div className="absolute -inset-2 pointer-events-none z-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg width="100%" height="100%" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="100" y1="0" x2="100" y2="20" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="100" y1="180" x2="100" y2="200" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="0" y1="100" x2="20" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="180" y1="100" x2="200" y2="100" stroke="#CC0000" strokeWidth="2" strokeLinecap="round" />
|
||||
<line x1="30" y1="30" x2="44" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="170" y1="30" x2="156" y2="44" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="30" y1="170" x2="44" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<line x1="170" y1="170" x2="156" y2="156" stroke="#0033CC" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="100" cy="100" r="90" stroke="#CC0000" strokeWidth="0.5" strokeDasharray="4 4" opacity="0.4" />
|
||||
<circle cx="100" cy="100" r="80" stroke="#0033CC" strokeWidth="0.5" strokeDasharray="3 5" opacity="0.3" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
|
||||
{/* Red/Blue gradient top strip */}
|
||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||
|
||||
<CardContent className="p-6 flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
|
||||
{isNumeric ? display : value}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs font-semibold",
|
||||
trend.up ? "text-emerald-500" : "text-rose-500"
|
||||
)}>
|
||||
{trend.up ? "↑" : "↓"} {trend.pct}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-xl shrink-0 ${color.bg}`}>
|
||||
<Icon className={`h-6 w-6 ${color.text}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sparkPath && (
|
||||
<div className="mt-auto relative">
|
||||
<svg width="60" height="28" viewBox="0 0 60 28" className="opacity-60 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<defs>
|
||||
<linearGradient id={`spark-fill-${title.replace(/\s/g, "")}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={sparkColor} stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor={sparkColor} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<filter id={`glow-${title.replace(/\s/g, "")}`}>
|
||||
<feGaussianBlur stdDeviation="2" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
{gridLines.map((gl, i) => (
|
||||
<line key={i} x1="0" y1={gl.y} x2="60" y2={gl.y} stroke="currentColor" strokeOpacity="0.08" strokeWidth="1" strokeDasharray="2,2" />
|
||||
))}
|
||||
<path d={sparkArea} fill={`url(#spark-fill-${title.replace(/\s/g, "")})`} />
|
||||
<path
|
||||
d={sparkPath}
|
||||
fill="none"
|
||||
stroke={sparkColor}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="animate-pulse"
|
||||
filter={`url(#glow-${title.replace(/\s/g, "")})`}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute -bottom-0.5 right-0 text-[9px] font-medium text-muted-foreground/60">
|
||||
/{goal}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{title === "Conversion Rate" && conversionRate !== undefined && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<div className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent to-transparent",
|
||||
color.glow
|
||||
)} />
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user