Compare commits
44 Commits
aac9817ee7
..
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 | |||
| 886a7efe91 |
@@ -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
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* ============================================================================
|
||||
Default Theme — Clean, Professional SaaS Design Tokens
|
||||
This is the standard CRM appearance. No Spidey branding or assets.
|
||||
============================================================================ */
|
||||
|
||||
.theme-default {
|
||||
--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: 210 80% 52%;
|
||||
--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: 215 25% 22%;
|
||||
--input: 215 25% 22%;
|
||||
--ring: 210 80% 52%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: 222.2 84% 4.9%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 210 80% 52%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||
--sidebar-accent-foreground: 210 40% 98%;
|
||||
--sidebar-border: 215 25% 22%;
|
||||
--sidebar-ring: 210 80% 52%;
|
||||
}
|
||||
@@ -4,14 +4,37 @@
|
||||
============================================================================ */
|
||||
|
||||
.theme-spidey {
|
||||
--background: 240 18% 97%;
|
||||
--foreground: 240 8% 14%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 8% 14%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 8% 14%;
|
||||
--secondary: 220 75% 48%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 240 8% 92%;
|
||||
--muted-foreground: 240 5% 50%;
|
||||
--accent: 220 15% 90%;
|
||||
--accent-foreground: 220 60% 28%;
|
||||
--destructive: 0 70% 46%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 240 8% 84%;
|
||||
--input: 240 8% 84%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar: 0 0% 99%;
|
||||
--sidebar-foreground: 240 8% 14%;
|
||||
--sidebar-accent: 220 20% 93%;
|
||||
--sidebar-accent-foreground: 220 70% 28%;
|
||||
--sidebar-border: 240 8% 84%;
|
||||
}
|
||||
|
||||
.dark.theme-spidey {
|
||||
--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%;
|
||||
@@ -22,16 +45,12 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 0 100% 53%;
|
||||
--radius: 0.5rem;
|
||||
--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%;
|
||||
}
|
||||
|
||||
.theme-spidey body::before {
|
||||
|
||||
+225
-74
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote,
|
||||
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote, Globe,
|
||||
} from "lucide-react"
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||
meeting: Users,
|
||||
call: Phone,
|
||||
chat: Video,
|
||||
follow_up: Clock,
|
||||
demo: Calendar,
|
||||
other: Calendar,
|
||||
website_creation: Globe,
|
||||
}
|
||||
|
||||
function eventVars(type: EventType) {
|
||||
@@ -85,14 +82,20 @@ export default function CalendarPage() {
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
|
||||
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formDescription, setFormDescription] = useState("")
|
||||
const [formType, setFormType] = useState<EventType>("meeting")
|
||||
const [formType, setFormType] = useState<EventType>("website_creation")
|
||||
const [formDate, setFormDate] = useState("")
|
||||
const [formTime, setFormTime] = useState("")
|
||||
const [formDuration, setFormDuration] = useState("30")
|
||||
const [formParticipantId, setFormParticipantId] = useState("")
|
||||
const [formDeveloperId, setFormDeveloperId] = useState("")
|
||||
const [formLeadId, setFormLeadId] = useState("")
|
||||
const [formClientName, setFormClientName] = useState("")
|
||||
const [formClientEmail, setFormClientEmail] = useState("")
|
||||
const [formClientPhone, setFormClientPhone] = useState("")
|
||||
const [formSaving, setFormSaving] = useState(false)
|
||||
const [editNotes, setEditNotes] = useState(false)
|
||||
const [notesText, setNotesText] = useState("")
|
||||
@@ -124,6 +127,10 @@ export default function CalendarPage() {
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
fetch("/api/leads?limit=200")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
|
||||
.catch(() => {})
|
||||
}, [user, router])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,7 +165,7 @@ export default function CalendarPage() {
|
||||
|
||||
const getEventsForDay = useCallback((day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
||||
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
|
||||
}, [events, currentYear, currentMonth])
|
||||
|
||||
const isToday = (day: number) => {
|
||||
@@ -169,9 +176,14 @@ export default function CalendarPage() {
|
||||
setEditingEvent(null)
|
||||
setFormTitle("")
|
||||
setFormDescription("")
|
||||
setFormType("meeting")
|
||||
setFormType("website_creation")
|
||||
setFormDuration("30")
|
||||
setFormParticipantId("")
|
||||
setFormDeveloperId("")
|
||||
setFormLeadId("")
|
||||
setFormClientName("")
|
||||
setFormClientEmail("")
|
||||
setFormClientPhone("")
|
||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||
setFormDate(d.toISOString().split("T")[0])
|
||||
setFormTime("10:00")
|
||||
@@ -184,9 +196,14 @@ export default function CalendarPage() {
|
||||
setFormDescription(event.description || "")
|
||||
setFormType(event.eventType)
|
||||
setFormDuration(String(event.durationMinutes || 30))
|
||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
||||
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
|
||||
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
|
||||
setFormParticipantId(event.participantId || "")
|
||||
setFormDeveloperId(event.developerId || "")
|
||||
setFormLeadId(event.leadId || "")
|
||||
setFormClientName(event.clientName || "")
|
||||
setFormClientEmail(event.clientEmail || "")
|
||||
setFormClientPhone(event.clientPhone || "")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
@@ -198,23 +215,35 @@ export default function CalendarPage() {
|
||||
|
||||
setFormSaving(true)
|
||||
try {
|
||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: formTitle.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
eventType: formType,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
||||
}
|
||||
|
||||
let startTime: string | undefined
|
||||
if (formType !== "website_creation") {
|
||||
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
body.startTime = startTime
|
||||
body.endTime = endTime
|
||||
body.durationMinutes = formDuration ? parseInt(formDuration) : null
|
||||
} else if (formDate) {
|
||||
startTime = new Date(`${formDate}T00:00:00`).toISOString()
|
||||
body.startTime = startTime
|
||||
}
|
||||
|
||||
if (formClientName) body.clientName = formClientName
|
||||
if (formClientEmail) body.clientEmail = formClientEmail
|
||||
if (formClientPhone) body.clientPhone = formClientPhone
|
||||
if (formParticipantId) body.participantId = formParticipantId
|
||||
if (formDeveloperId) body.developerId = formDeveloperId
|
||||
if (formLeadId) body.leadId = formLeadId
|
||||
|
||||
let res
|
||||
if (editingEvent) {
|
||||
@@ -235,11 +264,13 @@ export default function CalendarPage() {
|
||||
|
||||
const data = await res.json()
|
||||
if (editingEvent) {
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||
toast.success("Event updated")
|
||||
} else {
|
||||
setEvents((prev) => [...prev, data.event])
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
if (startTime) {
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
}
|
||||
toast.success("Event created")
|
||||
}
|
||||
|
||||
@@ -260,7 +291,7 @@ export default function CalendarPage() {
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to update")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||
toast.success(`Event ${newStatus}`)
|
||||
setDetailEvent(null)
|
||||
} catch {
|
||||
@@ -446,6 +477,11 @@ export default function CalendarPage() {
|
||||
>
|
||||
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
||||
<span className="truncate font-medium">{event.title}</span>
|
||||
{event.lead && (
|
||||
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
|
||||
{event.lead.contactName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -464,6 +500,15 @@ export default function CalendarPage() {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Unscheduled projects bar */}
|
||||
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
|
||||
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
|
||||
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
|
||||
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -534,12 +579,19 @@ export default function CalendarPage() {
|
||||
{event.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(event.startTime)}
|
||||
<span className="text-muted-foreground/20">·</span>
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
{event.startTime ? (
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatDate(event.startTime)}
|
||||
<span className="text-muted-foreground/20">·</span>
|
||||
{formatTime(event.startTime)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
|
||||
<Globe className="h-3 w-3" />
|
||||
Website project — no schedule
|
||||
</div>
|
||||
)}
|
||||
{event.durationMinutes && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
|
||||
<Zap className="h-2.5 w-2.5" />
|
||||
@@ -547,21 +599,20 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className={cn(
|
||||
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
|
||||
event.userId === user?.id
|
||||
? "bg-primary/10 text-primary ring-primary/20"
|
||||
: "bg-muted text-muted-foreground/60 ring-border",
|
||||
)}>
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name.charAt(0) || "?")
|
||||
: (event.creator?.name.charAt(0) || "?")}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
{event.userId === user?.id
|
||||
? (event.participant?.name || "No participant")
|
||||
: (event.creator?.name || "Unknown")}
|
||||
</span>
|
||||
{event.developerId === user?.id ? (
|
||||
<>
|
||||
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
|
||||
{event.creator?.name.charAt(0) || "?"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
Assigned by {event.creator?.name || "Unknown"}
|
||||
</span>
|
||||
</>
|
||||
) : event.lead ? (
|
||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||
{event.lead.contactName}{event.lead.companyName ? ` — ${event.lead.companyName}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,7 +630,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
@@ -608,11 +659,28 @@ export default function CalendarPage() {
|
||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
{formType === "website_creation" && (
|
||||
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
|
||||
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
|
||||
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
|
||||
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||
@@ -621,19 +689,16 @@ export default function CalendarPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="meeting">Meeting</SelectItem>
|
||||
<SelectItem value="website_creation">Website Creation</SelectItem>
|
||||
<SelectItem value="call">Call</SelectItem>
|
||||
<SelectItem value="chat">Video Chat</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="demo">Demo</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||
<SelectTrigger id="duration" className="h-10">
|
||||
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -664,8 +729,40 @@ export default function CalendarPage() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
||||
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
|
||||
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="developer" className="h-10">
|
||||
<SelectValue placeholder="Select a developer…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} {u.role ? `(${u.role})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
|
||||
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="lead" className="h-10">
|
||||
<SelectValue placeholder="Select a client lead…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{leads.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.contactName}{l.companyName ? ` — ${l.companyName}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
|
||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
||||
@@ -690,7 +787,7 @@ export default function CalendarPage() {
|
||||
{/* Event Detail Dialog */}
|
||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||
{detailEvent && (
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||
@@ -711,22 +808,29 @@ export default function CalendarPage() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Date / Time */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
{detailEvent.startTime ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
|
||||
<p className="text-sm text-muted-foreground/50 italic">No scheduled time — website creation project</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration + Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -751,11 +855,26 @@ export default function CalendarPage() {
|
||||
{/* Description */}
|
||||
{detailEvent.description && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Client details */}
|
||||
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
Client Details
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
|
||||
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
|
||||
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked lead */}
|
||||
{detailEvent.lead && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
@@ -802,6 +921,32 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Developer */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
|
||||
</p>
|
||||
{detailEvent.developer ? (
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
|
||||
{detailEvent.developer.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.developer.name}</span>
|
||||
{detailEvent.developer.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
|
||||
)}
|
||||
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
|
||||
Assigned by {detailEvent.creator?.name || "Unknown"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participant Notes (editable by both parties) */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -836,9 +981,15 @@ export default function CalendarPage() {
|
||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||
{detailEvent.status === "scheduled" && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
{detailEvent.developerId === user?.id ? (
|
||||
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
|
||||
export type EventType = "call" | "follow_up" | "website_creation"
|
||||
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
|
||||
@@ -17,6 +18,10 @@ export interface CalendarEvent {
|
||||
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
|
||||
}
|
||||
|
||||
+35
-35
@@ -12,6 +12,7 @@ import http from "node:http"
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { spawn } from "node:child_process"
|
||||
import crypto from "node:crypto"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -44,6 +45,8 @@ 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 || "llama3.2:3b"
|
||||
const SCRAPER_URL = process.env.SCRAPER_URL || "http://127.0.0.1:3008"
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || "http://127.0.0.1:3006"
|
||||
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")
|
||||
@@ -130,19 +133,22 @@ async function scrapeFacebook() {
|
||||
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||
try {
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const req = http.request({ hostname: "127.0.0.1", port: 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
|
||||
const parsed = new URL(SCRAPER_URL)
|
||||
let done = false
|
||||
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (res) => {
|
||||
let data = ""
|
||||
res.on("data", (c) => data += c)
|
||||
res.on("end", () => resolve(data))
|
||||
res.on("error", reject)
|
||||
res.on("end", () => { done = true; resolve(data) })
|
||||
res.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||
})
|
||||
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
|
||||
req.on("error", reject)
|
||||
req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
|
||||
req.on("error", (e) => { if (!done) { done = true; reject(e) } })
|
||||
req.end()
|
||||
})
|
||||
const data = JSON.parse(body)
|
||||
return data
|
||||
} catch (e) {
|
||||
console.error("scrapeFacebook error:", e.message)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -195,6 +201,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
||||
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
@@ -313,18 +320,20 @@ const server = http.createServer(async (req, res) => {
|
||||
if (req.method === "GET" && pathname === "/status") {
|
||||
const { default: http } = await import("http")
|
||||
const results = { ai: true }
|
||||
// Check scraper (port 3008)
|
||||
// Check scraper
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
const r = http.get(`${SCRAPER_URL}/health`, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||
r.on("error", reject)
|
||||
})
|
||||
results.scraper = true
|
||||
} catch { results.scraper = false }
|
||||
// Check frontend (port 3006)
|
||||
// Check frontend
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
const r = http.get(FRONTEND_URL, { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
||||
r.on("timeout", () => { r.destroy(); reject(new Error("timeout")) })
|
||||
r.on("error", reject)
|
||||
})
|
||||
results.frontend = true
|
||||
@@ -368,8 +377,8 @@ const server = http.createServer(async (req, res) => {
|
||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||
|
||||
try {
|
||||
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
||||
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
|
||||
await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
|
||||
const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
|
||||
for (const [b, p] of Object.entries(profiles)) {
|
||||
if (p) browsers[b] = { path: p }
|
||||
}
|
||||
@@ -377,7 +386,7 @@ const server = http.createServer(async (req, res) => {
|
||||
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
|
||||
for (const [b, v] of detectedList) {
|
||||
try {
|
||||
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
|
||||
const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ browser: b, profile_path: v.path }),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
@@ -536,38 +545,29 @@ const server = http.createServer(async (req, res) => {
|
||||
// Accepts { message, user_id?, user_role? } and returns AI response.
|
||||
// user_role must be "sales", "admin", or "super_admin" if provided.
|
||||
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()
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const rawBody = Buffer.concat(chunks).toString()
|
||||
const body = JSON.parse(rawBody)
|
||||
processRequest(req, res, body, startTime)
|
||||
} catch {
|
||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
||||
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")
|
||||
sendJSON(res, 200, { response })
|
||||
} catch (e) {
|
||||
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
|
||||
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 fallback
|
||||
sendJSON(res, 404, { error: "Not found" })
|
||||
} catch (err) {
|
||||
|
||||
+790
-250
File diff suppressed because it is too large
Load Diff
+128
-26
@@ -1,40 +1,142 @@
|
||||
# AI Sales Assistant — Self-Improvement Instructions
|
||||
# CRM AI Sales Assistant — Self-Knowledge
|
||||
|
||||
## Purpose
|
||||
This file contains the AI's own configuration, knowledge, and improvement rules.
|
||||
The AI can read and modify this file to update its behavior at runtime.
|
||||
## Identity
|
||||
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||
|
||||
## Current Instructions
|
||||
- Always respond in English
|
||||
- Keep responses under 300 words unless asked for detail
|
||||
- Use bullet points for lists
|
||||
- Be direct and actionable — no fluff
|
||||
- Never mention being an AI or language model
|
||||
- Refer to the user by their role (salesperson, admin, etc.)
|
||||
- If unsure about a topic, say "I don't have that information yet" rather than guessing
|
||||
## Architecture
|
||||
```
|
||||
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||
↓
|
||||
PostgreSQL (conversations)
|
||||
|
||||
## Knowledge Base
|
||||
### Sales Tips
|
||||
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||
```
|
||||
|
||||
Three services run concurrently:
|
||||
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||
|
||||
## 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)
|
||||
- Run Facebook scraper to find real leads asking for services
|
||||
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||
|
||||
## Facebook Scraper
|
||||
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||
|
||||
### How It Works
|
||||
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||
6. **Stealth mechanics**:
|
||||
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||
- Profile directory is temp-copied and cleaned up after each scrape
|
||||
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||
7. **2-pass classification (dead-accurate)**:
|
||||
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||
|
||||
### Lead Categories
|
||||
Two categories, selectable when starting a scrape:
|
||||
|
||||
**Website Creation:**
|
||||
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||
|
||||
**Tutoring:**
|
||||
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||
- Request terms: same as website category — must co-occur with a target keyword
|
||||
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||
|
||||
### Multi-Language Pipeline (Phase Order)
|
||||
4 South African languages in structured phases:
|
||||
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||
|
||||
### Output Format
|
||||
Each lead returned includes:
|
||||
- `title` — post preview text
|
||||
- `author` — poster's name (may include location in name)
|
||||
- `content` — extracted post text
|
||||
- `url` — direct link to the post
|
||||
- `date` — when posted (filtered within 7 days)
|
||||
- `category` — "website" or "tutor"
|
||||
|
||||
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||
|
||||
### Configuration via Env Vars
|
||||
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||
|
||||
## How to Start Scraping
|
||||
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||
2. Open the frontend at `http://localhost:3006`
|
||||
3. Select a job category (Website Creation or Tutoring)
|
||||
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||
5. Leads are saved in the CRM for follow-up
|
||||
|
||||
## Sales Tips
|
||||
- Cold emails should be under 150 words
|
||||
- Follow up within 48 hours
|
||||
- Personalise every outreach with the prospect's name and company
|
||||
- Use open-ended questions in discovery calls
|
||||
- Always ask for the next step before ending a call
|
||||
- For website leads: mention specific pages or features they requested
|
||||
- For tutoring leads: reference the subject and age group they mentioned
|
||||
|
||||
### Job Targeting
|
||||
## Job Targeting
|
||||
- Developers respond best to technical value props
|
||||
- Marketing managers care about ROI and metrics
|
||||
- C-level executives want brevity and business impact
|
||||
- Parents hiring tutors: empathy and qualifications matter most
|
||||
|
||||
## 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
|
||||
- When asked about scraping, give specific guidance on categories and languages
|
||||
|
||||
## 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
|
||||
|
||||
## Improvement Log
|
||||
Track changes made by the AI to improve itself:
|
||||
- (initial) Basic instructions and knowledge base created
|
||||
|
||||
## Self-Modification Rules
|
||||
The AI may update this file when:
|
||||
1. It identifies a gap in its knowledge that would help salespeople
|
||||
2. It discovers a better way to structure responses
|
||||
3. A user explicitly requests an update to behavior
|
||||
4. It notices repeated questions that aren't well-covered
|
||||
|
||||
Only append to the Improvement Log — don't delete previous entries.
|
||||
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||
|
||||
+2
-10
@@ -1,10 +1,2 @@
|
||||
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"}
|
||||
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
||||
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
|
||||
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
|
||||
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
|
||||
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
|
||||
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
|
||||
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
|
||||
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
|
||||
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
|
||||
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
|
||||
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
|
||||
|
||||
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
|
||||
-- admin_demo / AdminAccess@2026
|
||||
-- sales_demo / SalesAccess@2026
|
||||
-- dev_demo / DevTesting@2026
|
||||
-- ewan (password change required on first login)
|
||||
-- caitlin (password change required on first login)
|
||||
-- dillen (password change required on first login)
|
||||
|
||||
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',
|
||||
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
|
||||
'Sales', 'User', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||
'Dev', 'User', TRUE, TRUE)
|
||||
'Dev', 'User', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
|
||||
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
|
||||
'Ewan', 'Scheepers', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
|
||||
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
|
||||
'Caitlin', 'Hermanus', TRUE, TRUE),
|
||||
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
|
||||
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
|
||||
'Dillen', 'van der Merwe', TRUE, TRUE)
|
||||
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||
|
||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||
@@ -217,7 +229,7 @@ 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')
|
||||
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
|
||||
AND created_by IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
|
||||
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')
|
||||
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
|
||||
@@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL
|
||||
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
||||
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
||||
|
||||
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
|
||||
-- They will set their own passwords on first login (password_change_required=TRUE).
|
||||
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
|
||||
-- after users have set their chosen passwords.
|
||||
|
||||
-- ============================================================================
|
||||
-- FUTURE REQUIREMENT NOTE:
|
||||
-- If this system is ever exposed publicly, remove reversible password storage
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE scheduled_events
|
||||
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE scheduled_events
|
||||
DROP CONSTRAINT IF EXISTS chk_event_type,
|
||||
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
|
||||
|
||||
ALTER TABLE scheduled_events
|
||||
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE scheduled_events
|
||||
ALTER COLUMN start_time DROP NOT NULL;
|
||||
@@ -0,0 +1,31 @@
|
||||
-- ============================================================================
|
||||
-- Fixes: password_change_required + audit trigger UUID cast
|
||||
-- ============================================================================
|
||||
|
||||
-- 1. All users use the passwords already set in the seed — no forced change
|
||||
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
|
||||
|
||||
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
|
||||
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
|
||||
CREATE OR REPLACE FUNCTION audit_password_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
|
||||
INSERT INTO audit_logs (
|
||||
table_name, record_id, action, old_data, new_data, changed_by, ip_address
|
||||
) VALUES (
|
||||
'users',
|
||||
NEW.id,
|
||||
'UPDATE',
|
||||
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
|
||||
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
|
||||
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
|
||||
NULLIF(current_setting('app.current_ip', true), '')
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
|
||||
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- ============================================================================
|
||||
-- Performance & Missing Indexes
|
||||
-- ============================================================================
|
||||
|
||||
-- scheduled_events: index on created_at for list ordering
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_created_at ON scheduled_events(created_at DESC);
|
||||
|
||||
-- scheduled_events: composite index for common user+status queries
|
||||
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user_status ON scheduled_events(user_id, status);
|
||||
|
||||
-- messages: index on sender_id for delete-by-sender queries
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id);
|
||||
|
||||
-- messages: composite for conversation listing
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sender ON messages(conversation_id, sender_id);
|
||||
|
||||
-- notifications: composite unread badge query
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read, created_at DESC);
|
||||
|
||||
-- ai_conversations: composite user+created query
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_conversations_user_created ON ai_conversations(user_id, created_at DESC);
|
||||
|
||||
-- login_attempts: TTL cleanup
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_cleanup ON login_attempts(attempted_at) WHERE was_successful = false;
|
||||
|
||||
-- facebook_scrape_logs: composite account+created index (replaces separate one)
|
||||
DROP INDEX IF EXISTS idx_fb_scrape_logs_account;
|
||||
CREATE INDEX IF NOT EXISTS idx_fb_scrape_logs_account_created ON facebook_scrape_logs(account_id, created_at DESC);
|
||||
|
||||
-- lead_conversions: composite lead+customer (replaces two separate indexes)
|
||||
CREATE INDEX IF NOT EXISTS idx_lead_conversions_lead_customer ON lead_conversions(lead_id, customer_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Cache current_user_hierarchy_level as session variable for RLS performance
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_session_user_context(p_user_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
DECLARE
|
||||
level INT;
|
||||
BEGIN
|
||||
SELECT MIN(r.hierarchy_level) INTO level
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = p_user_id;
|
||||
|
||||
PERFORM set_config('app.current_user_id', p_user_id::TEXT, true);
|
||||
PERFORM set_config('app.current_user_hierarchy_level', COALESCE(level::TEXT, ''), true);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Update current_user_hierarchy_level() to use the cached session variable
|
||||
CREATE OR REPLACE FUNCTION current_user_hierarchy_level()
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
level_text TEXT;
|
||||
BEGIN
|
||||
BEGIN
|
||||
level_text := NULLIF(current_setting('app.current_user_hierarchy_level', true), '');
|
||||
IF level_text IS NOT NULL THEN
|
||||
RETURN level_text::INT;
|
||||
END IF;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
NULL;
|
||||
END;
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql STABLE;
|
||||
@@ -66,5 +66,21 @@ BEGIN;
|
||||
|
||||
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
|
||||
\i 016_participant_notes.sql
|
||||
|
||||
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
|
||||
\i 017_calendar_developer.sql
|
||||
|
||||
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
|
||||
\i 018_website_creation_type.sql
|
||||
|
||||
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
||||
\i 019_allow_null_start_time.sql
|
||||
|
||||
\echo '=== Running 020_fixes.sql (password_change_required + audit trigger fix) ==='
|
||||
\i 020_fixes.sql
|
||||
|
||||
\echo '=== Running 021_performance_indexes.sql (Performance Indexes + RLS Cache) ==='
|
||||
\i 021_performance_indexes.sql
|
||||
|
||||
\echo '=== Migration Complete ==='
|
||||
COMMIT;
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals.js";
|
||||
import nextTs from "eslint-config-next/typescript.js";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
|
||||
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: [
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
|
||||
Generated
+1535
-28
File diff suppressed because it is too large
Load Diff
+18
-8
@@ -13,14 +13,22 @@
|
||||
"dev:rust": "node ai-server/index.mjs",
|
||||
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
|
||||
"setup": "node scripts/setup.mjs",
|
||||
"migrate": "node scripts/run-migrations.mjs",
|
||||
"db:migrate": "npm run migrate",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3006",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"ci": "npm run lint && npx tsc --noEmit && npm test",
|
||||
"repair": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:watch": "echo 'Auto-repair disabled for security. Fix errors manually.'",
|
||||
"repair:ci": "echo 'Auto-repair disabled for security. Fix errors manually.'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.1.4",
|
||||
@@ -42,7 +50,6 @@
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"devenv": "^1.0.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"framer-motion": "^11.15.0",
|
||||
"jose": "^6.2.3",
|
||||
@@ -63,17 +70,20 @@
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@next/swc-win32-x64-msvc": "^15.0.4",
|
||||
"@types/node": "20.19.43",
|
||||
"@types/nodemailer": "8.0.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "18.3.31",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"concurrently": "^10.0.3",
|
||||
"devenv": "1.0.1",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"maildev": "^2.2.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^1.6.1"
|
||||
}
|
||||
}
|
||||
|
||||
+122
-23
@@ -1,9 +1,23 @@
|
||||
# CRM AI Service — Self-Knowledge
|
||||
# CRM AI Sales Assistant — 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.
|
||||
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||
You run on a Node.js backend (port 3001) and use Ollama with a local model (dolphin3-llama3.2:3b).
|
||||
Your purpose is to help salespeople close more deals by finding and engaging leads.
|
||||
|
||||
## Architecture
|
||||
```
|
||||
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||
↓
|
||||
PostgreSQL (conversations)
|
||||
|
||||
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||
```
|
||||
|
||||
Three services run concurrently:
|
||||
- **AI Server** (`ai-server/index.mjs`, port 3001) — chat, setup wizard, config endpoints
|
||||
- **Frontend** (Next.js, port 3006) — UI for salespeople
|
||||
- **Scraper** (`browser-use-service/main.py`, port 3008) — Facebook lead discovery
|
||||
|
||||
## Capabilities
|
||||
- Give sales tips and strategies per job category
|
||||
@@ -11,29 +25,103 @@ Your purpose is to help salespeople close more deals.
|
||||
- Handle objections with proven rebuttals
|
||||
- Analyse prospect behaviour and suggest next steps
|
||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
||||
- Run Facebook scraper to find real leads asking for services
|
||||
- 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
|
||||
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||
|
||||
## 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`.
|
||||
### How It Works
|
||||
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||
3. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
|
||||
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
|
||||
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
|
||||
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
|
||||
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
|
||||
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
|
||||
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
|
||||
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
|
||||
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
|
||||
6. **Stealth mechanics**:
|
||||
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
|
||||
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
|
||||
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
|
||||
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
|
||||
- Random decoy page visits (e.g., Facebook Groups) between searches
|
||||
- Profile directory is temp-copied and cleaned up after each scrape
|
||||
- Detection signal monitoring (checkpoint, login pages, security challenges)
|
||||
7. **2-pass classification (dead-accurate)**:
|
||||
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
|
||||
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
|
||||
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
|
||||
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
|
||||
|
||||
## 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
|
||||
### Lead Categories
|
||||
Two categories, selectable when starting a scrape:
|
||||
|
||||
**Website Creation:**
|
||||
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
|
||||
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
|
||||
|
||||
**Tutoring:**
|
||||
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
|
||||
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
|
||||
- Request terms: same as website category — must co-occur with a target keyword
|
||||
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
|
||||
|
||||
### Multi-Language Pipeline (Phase Order)
|
||||
4 South African languages in structured phases:
|
||||
- **Phase 1 (English)**: primary query + supplementary English searches
|
||||
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
|
||||
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
|
||||
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
|
||||
|
||||
### Output Format
|
||||
Each lead returned includes:
|
||||
- `title` — post preview text
|
||||
- `author` — poster's name (may include location in name)
|
||||
- `content` — extracted post text
|
||||
- `url` — direct link to the post
|
||||
- `date` — when posted (filtered within 2 days)
|
||||
- `category` — "website" or "tutor"
|
||||
|
||||
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
|
||||
|
||||
### Configuration via Env Vars
|
||||
- `SELECTED_BROWSER` — `firefox` (default), `chrome`, `opera`, `edge`, or `auto`
|
||||
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
|
||||
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
|
||||
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
|
||||
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
|
||||
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
|
||||
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
|
||||
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
|
||||
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
|
||||
|
||||
## How to Start Scraping
|
||||
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
|
||||
2. Open the frontend at `http://localhost:3006`
|
||||
3. Select a job category (Website Creation or Tutoring)
|
||||
4. Click "Search Facebook" — the scraper runs and returns leads
|
||||
5. Leads are saved in the CRM for follow-up
|
||||
|
||||
## Sales Tips
|
||||
- Cold emails should be under 150 words
|
||||
- Follow up within 48 hours
|
||||
- Personalise every outreach with the prospect's name and company
|
||||
- Use open-ended questions in discovery calls
|
||||
- Always ask for the next step before ending a call
|
||||
- For website leads: mention specific pages or features they requested
|
||||
- For tutoring leads: reference the subject and age group they mentioned
|
||||
|
||||
## Job Targeting
|
||||
- Developers respond best to technical value props
|
||||
- Marketing managers care about ROI and metrics
|
||||
- C-level executives want brevity and business impact
|
||||
- Parents hiring tutors: empathy and qualifications matter most
|
||||
|
||||
## Response Rules
|
||||
- Be direct and actionable — no fluff, no AI disclaimers
|
||||
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
|
||||
- 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
|
||||
- When asked about scraping, give specific guidance on categories and languages
|
||||
|
||||
## 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
|
||||
|
||||
## Improvement Log
|
||||
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::{HeaderMap, Method, StatusCode},
|
||||
http::{HeaderMap, HeaderValue, Method, StatusCode},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
@@ -482,11 +482,12 @@ async fn main() {
|
||||
rate_limiter: RateLimiter::new(30, 60),
|
||||
});
|
||||
|
||||
let cors_origins_env = std::env::var("CORS_ORIGINS").unwrap_or_else(|_| "http://localhost:3006,http://127.0.0.1:3006".to_string());
|
||||
let cors_origins: Vec<HeaderValue> = cors_origins_env.split(',')
|
||||
.filter_map(|o| { let t = o.trim(); if t.is_empty() { None } else { t.parse().ok() } })
|
||||
.collect();
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::list([
|
||||
"http://localhost:3006".parse().unwrap(),
|
||||
"http://127.0.0.1:3006".parse().unwrap(),
|
||||
]))
|
||||
.allow_origin(AllowOrigin::list(cors_origins))
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
.allow_headers(Any);
|
||||
|
||||
@@ -506,7 +507,7 @@ async fn main() {
|
||||
|
||||
let bg_leads = lead_store.clone();
|
||||
let bg_db = state.db.clone();
|
||||
let bg_url = "http://localhost:3008/scrape/facebook".to_string();
|
||||
let bg_url = std::env::var("SCRAPER_URL").unwrap_or_else(|_| "http://localhost:3008".to_string()) + "/scrape/facebook";
|
||||
tokio::spawn(async move {
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(300))
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
// ── Code Repair Agent ─────────────────────────────────────────────
|
||||
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
|
||||
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
|
||||
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
|
||||
//
|
||||
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
|
||||
// Model: qwen2.5-coder:1.5b-base
|
||||
//
|
||||
// ── Architecture ──────────────────────────────────────────────────
|
||||
// 1. Capture build output from `npx tsc --noEmit`
|
||||
// 2. Parse error locations (file, line, column, message)
|
||||
// 3. Read the failing source file + surrounding context (~20 lines)
|
||||
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
|
||||
// 5. Apply fix if confidence > threshold (0.7)
|
||||
// 6. Re-run build to verify fix
|
||||
// 7. If breaking change detected (wide-reaching import changes), escalate
|
||||
// 8. In --ci mode: auto-commit with [bot] prefix
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const SELF = dirname(fileURLToPath(import.meta.url))
|
||||
const _require = createRequire(import.meta.url)
|
||||
const ROOT = resolve(SELF, "..")
|
||||
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
|
||||
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
|
||||
const MAX_ITERATIONS = 5
|
||||
const CONFIDENCE_THRESHOLD = 0.7
|
||||
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function log(msg, level = "info") {
|
||||
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
|
||||
console.log(` ${prefix} [repair] ${msg}`)
|
||||
}
|
||||
|
||||
function runTsc() {
|
||||
try {
|
||||
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
|
||||
return { ok: true, output: out.toString() }
|
||||
} catch (e) {
|
||||
return { ok: false, output: e.stdout?.toString() || e.message || "" }
|
||||
}
|
||||
}
|
||||
|
||||
function parseErrors(output) {
|
||||
const errors = []
|
||||
// Match: src/file.ts(line,col): error TSxxxx: message
|
||||
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
|
||||
let m
|
||||
while ((m = lineRegex.exec(output)) !== null) {
|
||||
errors.push({
|
||||
file: resolve(ROOT, m[1]),
|
||||
line: parseInt(m[2], 10),
|
||||
column: parseInt(m[3], 10),
|
||||
message: m[4].trim(),
|
||||
})
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
function readContext(filePath, errorLine, contextLines = 20) {
|
||||
try {
|
||||
const lines = readFileSync(filePath, "utf-8").split("\n")
|
||||
const start = Math.max(0, errorLine - 1 - contextLines)
|
||||
const end = Math.min(lines.length, errorLine - 1 + contextLines)
|
||||
return {
|
||||
content: lines.slice(start, end).join("\n"),
|
||||
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
|
||||
totalLines: lines.length,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ollama Integration ─────────────────────────────────────────────
|
||||
|
||||
async function queryOllama(prompt) {
|
||||
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
prompt,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_predict: 2048 },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
|
||||
const data = await response.json()
|
||||
return data.response || ""
|
||||
}
|
||||
|
||||
function buildRepairPrompt(filePath, errorMessage, codeContext) {
|
||||
return `You are a TypeScript repair agent. Fix the following error.
|
||||
|
||||
FILE: ${filePath}
|
||||
ERROR: ${errorMessage}
|
||||
|
||||
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
|
||||
\`\`\`typescript
|
||||
${codeContext.content}
|
||||
\`\`\`
|
||||
|
||||
Respond with ONLY the fix JSON in this exact format:
|
||||
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
|
||||
|
||||
Rules:
|
||||
- Keep the fix minimal and precise
|
||||
- Only change what's necessary to fix the error
|
||||
- If the error is a missing import, suggest the import statement
|
||||
- If the error is a type mismatch, suggest the corrected code
|
||||
- confidence < 0.7 will not be auto-applied
|
||||
- If you cannot determine a fix, set confidence to 0`
|
||||
}
|
||||
|
||||
function parseFixResponse(text) {
|
||||
try {
|
||||
// Extract JSON from response (it might have markdown fences)
|
||||
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
|
||||
if (!jsonMatch) return null
|
||||
return JSON.parse(jsonMatch[0])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function applyFix(filePath, fixSuggestion) {
|
||||
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
|
||||
|
||||
try {
|
||||
const original = readFileSync(filePath, "utf-8")
|
||||
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
|
||||
return { applied: true, backup: original }
|
||||
} catch (e) {
|
||||
return { applied: false, reason: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Breaking Change Escalation ─────────────────────────────────────
|
||||
|
||||
function isBreakingChange(errors, fixes) {
|
||||
// Heuristic: if fix touches import structure or exports, it's breaking
|
||||
for (const fix of fixes) {
|
||||
if (!fix || !fix.fix) continue
|
||||
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function escalateBreakingChange(errors, fixes) {
|
||||
const logFile = resolve(ROOT, "repair-escalation.log")
|
||||
const timestamp = new Date().toISOString()
|
||||
const entry = [
|
||||
`=== Breaking Change Escalation @ ${timestamp} ===`,
|
||||
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
|
||||
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
|
||||
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
|
||||
"========================================\n",
|
||||
].join("\n")
|
||||
|
||||
appendFileSync(logFile, entry)
|
||||
log(`Breaking change escalated — see ${logFile}`, "warn")
|
||||
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
|
||||
|
||||
// Wait for timeout or user intervention
|
||||
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
|
||||
log("Escalation timeout reached — applying fixes")
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Git Auto-Commit ────────────────────────────────────────────────
|
||||
|
||||
function gitCommit(message) {
|
||||
try {
|
||||
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
|
||||
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
|
||||
log(`Committed: [bot] ${message}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
log(`Git commit failed: ${e.message}`, "warn")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Repair Loop ───────────────────────────────────────────────
|
||||
|
||||
async function repairOnce(doCommit = false) {
|
||||
log("Running build check...")
|
||||
const build = runTsc()
|
||||
if (build.ok) {
|
||||
log("No build errors found")
|
||||
return { fixed: false, errors: [] }
|
||||
}
|
||||
|
||||
const errors = parseErrors(build.output)
|
||||
if (errors.length === 0) {
|
||||
log("Could not parse build errors from output", "warn")
|
||||
log(build.output.slice(0, 1000))
|
||||
return { fixed: false, errors: [] }
|
||||
}
|
||||
|
||||
log(`Found ${errors.length} error(s):`)
|
||||
for (const e of errors.slice(0, 10)) {
|
||||
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
|
||||
log(`${shortFile}:${e.line}:${e.column} — ${e.message}`)
|
||||
}
|
||||
|
||||
const fixes = []
|
||||
for (const error of errors) {
|
||||
const context = readContext(error.file, error.line)
|
||||
if (!context) {
|
||||
log(`Cannot read ${error.file}`, "error")
|
||||
continue
|
||||
}
|
||||
|
||||
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
|
||||
try {
|
||||
const prompt = buildRepairPrompt(error.file, error.message, context)
|
||||
const response = await queryOllama(prompt)
|
||||
const fix = parseFixResponse(response)
|
||||
|
||||
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
|
||||
const result = applyFix(error.file, fix)
|
||||
if (result.applied) {
|
||||
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
|
||||
fixes.push(fix)
|
||||
} else {
|
||||
log(`Failed to apply fix: ${result.reason}`, "error")
|
||||
}
|
||||
} else {
|
||||
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Ollama error: ${e.message}`, "error")
|
||||
}
|
||||
}
|
||||
|
||||
if (fixes.length === 0) {
|
||||
log("No fixes could be applied", "error")
|
||||
return { fixed: false, errors }
|
||||
}
|
||||
|
||||
// Verify by re-running build
|
||||
log("Verifying fixes...")
|
||||
const verify = runTsc()
|
||||
if (verify.ok) {
|
||||
log("All fixes verified — build passes!")
|
||||
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
|
||||
return { fixed: true, errors: [], fixes }
|
||||
}
|
||||
|
||||
const remainingErrors = parseErrors(verify.output)
|
||||
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
|
||||
|
||||
// Check for breaking changes
|
||||
if (isBreakingChange(remainingErrors, fixes)) {
|
||||
log("Breaking change detected — escalating", "warn")
|
||||
const approved = await escalateBreakingChange(remainingErrors, fixes)
|
||||
if (approved) {
|
||||
if (doCommit) gitCommit(`Breaking change applied after escalation`)
|
||||
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
|
||||
}
|
||||
|
||||
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
|
||||
for (let i = 0; i < maxIterations; i++) {
|
||||
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
|
||||
const result = await repairOnce(doCommit)
|
||||
if (!result.fixed) {
|
||||
if (result.errors?.length > 0) {
|
||||
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
|
||||
}
|
||||
break
|
||||
}
|
||||
if (result.errors?.length === 0) break
|
||||
}
|
||||
log("Repair agent finished")
|
||||
}
|
||||
|
||||
// ── Watch Mode ─────────────────────────────────────────────────────
|
||||
|
||||
function watchMode() {
|
||||
log("Watch mode active — monitoring src/ for changes...", "info")
|
||||
const chokidar = (() => {
|
||||
try {
|
||||
return _require("chokidar")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
if (!chokidar) {
|
||||
log("chokidar not available — using simple polling (5s interval)", "warn")
|
||||
setInterval(async () => {
|
||||
const result = await repairOnce(false)
|
||||
if (result.fixed) log("Auto-fix applied")
|
||||
}, 5000)
|
||||
return
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
|
||||
let debounceTimer = null
|
||||
|
||||
watcher.on("change", async (filePath) => {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(async () => {
|
||||
log(`Change detected: ${filePath}`)
|
||||
await repairLoop(3, false)
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
log("Watching for changes...")
|
||||
}
|
||||
|
||||
// ── Entry ──────────────────────────────────────────────────────────
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const isWatch = args.includes("--watch") || args.includes("-w")
|
||||
const isCI = args.includes("--ci") || args.includes("-c")
|
||||
|
||||
async function main() {
|
||||
if (isWatch) {
|
||||
watchMode()
|
||||
} else {
|
||||
await repairLoop(MAX_ITERATIONS, isCI)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("Repair agent crashed:", e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,396 @@
|
||||
// ── Module Generator ─────────────────────────────────────────────────
|
||||
// Generates missing internal modules from templates.
|
||||
// Supports built-in templates and custom .setup-templates/ directory.
|
||||
|
||||
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, "..", "..");
|
||||
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
|
||||
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
|
||||
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
|
||||
|
||||
export interface TemplateRegistry {
|
||||
[importPath: string]: {
|
||||
template: string;
|
||||
params?: Record<string, unknown>;
|
||||
generator?: string; // optional custom generator function name
|
||||
};
|
||||
}
|
||||
|
||||
export interface GenerationResult {
|
||||
success: boolean;
|
||||
filePath: string;
|
||||
internalPath: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface FallbackRegistry {
|
||||
[pattern: string]: string;
|
||||
}
|
||||
|
||||
/** Load the template registry from .setup-templates/registry.json */
|
||||
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
|
||||
const builtInTemplates: TemplateRegistry = {
|
||||
"data/stickers": { template: "stickers.ts" },
|
||||
"data/constants": { template: "constants.ts" },
|
||||
"lib/utils": { template: "utils.ts" },
|
||||
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
|
||||
};
|
||||
const builtInFallbacks: FallbackRegistry = {
|
||||
"@/data/*": "data-generic.ts",
|
||||
"@/lib/*": "lib-generic.ts",
|
||||
"@/hooks/*": "hook-generic.ts",
|
||||
"@/types/*": "types-generic.ts",
|
||||
"@/components/*": "component-generic.tsx",
|
||||
"@/utils/*": "utils-generic.ts",
|
||||
};
|
||||
|
||||
try {
|
||||
if (fs.existsSync(REGISTRY_PATH)) {
|
||||
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
||||
const custom = JSON.parse(content);
|
||||
return {
|
||||
templates: { ...builtInTemplates, ...(custom.templates || {}) },
|
||||
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Use built-in
|
||||
}
|
||||
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
|
||||
}
|
||||
|
||||
/** Match an internal path against fallback patterns */
|
||||
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
|
||||
for (const [pattern, template] of Object.entries(fallbacks)) {
|
||||
// Convert glob pattern to regex
|
||||
const regexStr = "^" + pattern
|
||||
.replace(/\//g, "\\/")
|
||||
.replace(/\./g, "\\.")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/[$^+(){}[\]|]/g, "\\$&")
|
||||
+ "$";
|
||||
const regex = new RegExp(regexStr);
|
||||
if (regex.test(internalPath)) {
|
||||
return template;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Render a template with parameters */
|
||||
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
|
||||
let result = templateContent;
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
|
||||
result = result.replace(regex, String(value));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get template content - first from custom templates dir, then built-in */
|
||||
function getTemplateContent(templateName: string): string {
|
||||
// Check custom templates first
|
||||
const customPath = path.join(TEMPLATES_DIR, templateName);
|
||||
if (fs.existsSync(customPath)) {
|
||||
return fs.readFileSync(customPath, "utf-8");
|
||||
}
|
||||
|
||||
// Built-in templates (embedded)
|
||||
const builtInTemplates: Record<string, string> = {
|
||||
"stickers.ts": `// ── 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[];
|
||||
}
|
||||
|
||||
export function getStickerPacks(): StickerPack[] {
|
||||
return [
|
||||
{
|
||||
id: "reactions",
|
||||
name: "Reactions",
|
||||
stickers: [
|
||||
{ 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: "🙏" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "animals",
|
||||
name: "Animals",
|
||||
stickers: [
|
||||
{ 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: "🐦" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "food",
|
||||
name: "Food & Drink",
|
||||
stickers: [
|
||||
{ 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: "🍪" },
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function getStickerPacks(): StickerPack[] {
|
||||
return [
|
||||
// reactions, animals, food packs defined above
|
||||
// (inline for brevity - full packs in template file)
|
||||
];
|
||||
}
|
||||
`,
|
||||
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
|
||||
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;
|
||||
`,
|
||||
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
`,
|
||||
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
|
||||
// Auto-generated by self-healing setup.
|
||||
|
||||
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;
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
return builtInTemplates[templateName] || "";
|
||||
}
|
||||
|
||||
/** Generate a single missing module */
|
||||
export function generateModule(
|
||||
internalPath: string,
|
||||
templates: TemplateRegistry,
|
||||
fallbacks: FallbackRegistry
|
||||
): GenerationResult {
|
||||
let entry = templates[internalPath];
|
||||
let templateFile = entry?.template;
|
||||
|
||||
// Try fallback patterns
|
||||
if (!entry) {
|
||||
const fallbackTemplate = matchFallback(internalPath, fallbacks);
|
||||
if (fallbackTemplate) {
|
||||
templateFile = fallbackTemplate;
|
||||
entry = { template: fallbackTemplate };
|
||||
}
|
||||
}
|
||||
|
||||
if (!templateFile) {
|
||||
return {
|
||||
success: false,
|
||||
filePath: "",
|
||||
internalPath,
|
||||
error: `No template registered for @/${internalPath}`,
|
||||
};
|
||||
}
|
||||
|
||||
const templateContent = getTemplateContent(templateFile);
|
||||
if (!templateContent) {
|
||||
return {
|
||||
success: false,
|
||||
filePath: "",
|
||||
internalPath,
|
||||
error: `Template not found: ${entry.template}`,
|
||||
};
|
||||
}
|
||||
|
||||
const rendered = renderTemplate(templateContent, entry.params || {});
|
||||
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
|
||||
|
||||
// Ensure directory exists
|
||||
const dir = path.dirname(filePath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// Write file
|
||||
try {
|
||||
fs.writeFileSync(filePath, rendered, "utf-8");
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
internalPath,
|
||||
message: `Generated @/${internalPath}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
filePath,
|
||||
internalPath,
|
||||
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate all missing modules for a list of internal paths */
|
||||
export function generateAll(internalPaths: string[]): GenerationResult[] {
|
||||
const { templates, fallbacks } = loadRegistry();
|
||||
const results: GenerationResult[] = [];
|
||||
|
||||
for (const internalPath of internalPaths) {
|
||||
const result = generateModule(internalPath, templates, fallbacks);
|
||||
results.push(result);
|
||||
if (result.success) {
|
||||
console.log(` ✓ ${result.message}`);
|
||||
} else {
|
||||
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// ── TypeScript Error Parser ──────────────────────────────────────────
|
||||
// Parses `tsc --noEmit` or `npm run build` output to extract
|
||||
// "Module not found: Can't resolve '@/path/to/module'" errors.
|
||||
// Returns actionable missing module info for the generator.
|
||||
|
||||
export interface MissingModule {
|
||||
/** The import path that failed, e.g. "@/data/stickers" */
|
||||
importPath: string;
|
||||
/** The file that contains the failing import */
|
||||
importerFile: string;
|
||||
/** Line number in importer file */
|
||||
line: number;
|
||||
/** Column number */
|
||||
column: number;
|
||||
/** Full error message for context */
|
||||
message: string;
|
||||
/** Whether this is an internal (@/...) module we can generate */
|
||||
isInternal: boolean;
|
||||
/** The normalized internal path, e.g. "data/stickers" */
|
||||
internalPath?: string;
|
||||
}
|
||||
|
||||
/** Parse TypeScript compiler output for missing module errors */
|
||||
export function parseMissingModules(tscOutput: string): MissingModule[] {
|
||||
const results: MissingModule[] = [];
|
||||
|
||||
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
|
||||
// File: src/components/chats/media-picker.tsx:10:1
|
||||
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
|
||||
|
||||
let match;
|
||||
while ((match = errorRegex.exec(tscOutput)) !== null) {
|
||||
const importPath = match[1];
|
||||
const importerFile = match[2] || "";
|
||||
const line = parseInt(match[3] || "0", 10);
|
||||
const column = parseInt(match[4] || "0", 10);
|
||||
|
||||
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
|
||||
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
|
||||
let altMatch;
|
||||
let altImporter = importerFile;
|
||||
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
|
||||
if (altMatch[1] === importPath) {
|
||||
altImporter = altMatch[2];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
|
||||
|
||||
let internalPath: string | undefined;
|
||||
if (isInternal) {
|
||||
internalPath = importPath.replace(/^[@~]\//, "");
|
||||
}
|
||||
|
||||
results.push({
|
||||
importPath,
|
||||
importerFile: altImporter,
|
||||
line,
|
||||
column,
|
||||
message: `Cannot find module '${importPath}'`,
|
||||
isInternal,
|
||||
internalPath,
|
||||
});
|
||||
}
|
||||
|
||||
// Deduplicate by importPath + importerFile
|
||||
const seen = new Set<string>();
|
||||
return results.filter((m) => {
|
||||
const key = `${m.importPath}|${m.importerFile}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/** Filter to only internal (@/...) modules we can auto-generate */
|
||||
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
|
||||
return modules.filter((m) => m.isInternal && m.internalPath);
|
||||
}
|
||||
|
||||
/** Group missing modules by internal path */
|
||||
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
|
||||
const groups = new Map<string, MissingModule[]>();
|
||||
for (const m of modules) {
|
||||
if (!m.internalPath) continue;
|
||||
const existing = groups.get(m.internalPath) || [];
|
||||
existing.push(m);
|
||||
groups.set(m.internalPath, existing);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
+32
-3
@@ -1,12 +1,41 @@
|
||||
// ── Dependency Check ─────────────────────────────────────────────────
|
||||
// Verifies all packages listed in package.json are installed.
|
||||
// Auto-runs npm install if any are missing — zero manual setup steps.
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { execSync } from "node:child_process"
|
||||
import { platform } from "node:os"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = resolve(__dirname, "..")
|
||||
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
|
||||
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
||||
|
||||
const missing = Object.keys(allDeps).filter(name => {
|
||||
const pkgDir = resolve(root, "node_modules", name)
|
||||
return !existsSync(resolve(pkgDir, "package.json"))
|
||||
})
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
|
||||
console.log("Installing...\n")
|
||||
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
|
||||
console.log("Dependencies installed\n")
|
||||
}
|
||||
} catch {
|
||||
// If package.json read or resolution fails, skip silently
|
||||
}
|
||||
|
||||
// ── Port Precheck ──────────────────────────────────────────────────
|
||||
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
|
||||
// These are the AI server, Next.js frontend, Signaling server, and
|
||||
// Python scraper respectively.
|
||||
// Runs before anything else starts to avoid EADDRINUSE errors.
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { platform } from "node:os"
|
||||
|
||||
const PORTS = [3001, 3006, 3007, 3008]
|
||||
|
||||
if (platform() === "win32") {
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// ── Automated Database Migration Runner ──────────────────────────────
|
||||
// Reads .env.local for DATABASE_URL, tracks applied migrations in
|
||||
// a _migrations table, and runs unapplied .sql files via psql.
|
||||
// Runs automatically on npm run dev and npm run setup.
|
||||
// ============================================================================
|
||||
|
||||
import { readFileSync, existsSync } from "node:fs"
|
||||
import { execSync } from "node:child_process"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(__dirname, "..")
|
||||
|
||||
// ── Load .env.local into process.env ─────────────────────────────────
|
||||
|
||||
const envPath = resolve(ROOT, ".env.local")
|
||||
if (existsSync(envPath)) {
|
||||
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith("#")) continue
|
||||
const eq = trimmed.indexOf("=")
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!process.env[key]) process.env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
if (!DATABASE_URL) {
|
||||
console.log(" ~ DATABASE_URL not set — skipping migrations")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── Find psql ────────────────────────────────────────────────────────
|
||||
|
||||
function findPsql() {
|
||||
try {
|
||||
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
|
||||
return "psql"
|
||||
} catch {}
|
||||
|
||||
const candidates = [
|
||||
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
|
||||
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
|
||||
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
|
||||
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
|
||||
]
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return `"${p}"`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const PSQL = findPsql()
|
||||
if (!PSQL) {
|
||||
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── Database connection (for tracking table) ─────────────────────────
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { Pool } = require("pg")
|
||||
|
||||
let pool
|
||||
try {
|
||||
pool = new Pool({
|
||||
connectionString: DATABASE_URL,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 5000,
|
||||
idleTimeoutMillis: 10000,
|
||||
})
|
||||
await pool.query("SELECT 1")
|
||||
} catch {
|
||||
console.log(" ~ Database not available — skipping migrations")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// ── Migration logic ──────────────────────────────────────────────────
|
||||
|
||||
async function runMigrations() {
|
||||
// 1. Create tracking table if not exists
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS _migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`)
|
||||
|
||||
// 2. Determine file order from run_all.sql (authoritative)
|
||||
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
||||
const runAllContent = readFileSync(runAllPath, "utf-8")
|
||||
const fileOrder = []
|
||||
for (const line of runAllContent.split("\n")) {
|
||||
const match = line.match(/\\i\s+(\S+\.sql)/)
|
||||
if (match) fileOrder.push(match[1])
|
||||
}
|
||||
|
||||
if (fileOrder.length === 0) {
|
||||
console.log(" ~ No migration files found in run_all.sql")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Get already-applied files
|
||||
let appliedSet
|
||||
try {
|
||||
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
|
||||
appliedSet = new Set(applied.map((r) => r.filename))
|
||||
} catch {
|
||||
appliedSet = new Set()
|
||||
}
|
||||
|
||||
// 4. Build psql command base
|
||||
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
|
||||
|
||||
// 5. Run unapplied files in order.
|
||||
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
|
||||
// If the error is "already exists", the file was already applied before
|
||||
// tracking existed — mark it as applied and continue.
|
||||
// Other errors are genuine failures — abort.
|
||||
const ALREADY_APPLIED_PATTERNS = [
|
||||
"already exists",
|
||||
"duplicate key",
|
||||
"cannot insert multiple commands into a prepared statement",
|
||||
]
|
||||
|
||||
let count = 0
|
||||
for (const file of fileOrder) {
|
||||
if (appliedSet.has(file)) continue
|
||||
|
||||
const filePath = resolve(ROOT, "database", "migrations", file)
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(` ✗ Migration file not found: ${file}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let stderr = ""
|
||||
try {
|
||||
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
|
||||
timeout: 30000,
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
})
|
||||
// psql outputs notices/warnings to stderr even on success
|
||||
stderr = result.stderr || ""
|
||||
} catch (err) {
|
||||
const msg = err.stderr || err.stdout || err.message
|
||||
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
||||
if (isAlreadyApplied) {
|
||||
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
|
||||
console.log(` ~ Already applied: ${file}`)
|
||||
count++
|
||||
continue
|
||||
}
|
||||
console.error(` ✗ Migration failed: ${file}`)
|
||||
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
||||
console.log(` ✓ Applied: ${file}`)
|
||||
count++
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
console.log(" ~ All migrations up to date")
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run ──────────────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
await runMigrations()
|
||||
} finally {
|
||||
await pool.end()
|
||||
}
|
||||
+145
-17
@@ -5,17 +5,22 @@
|
||||
// 2. pip install -r requirements.txt (Python dependencies)
|
||||
// 3. playwright install firefox chromium (Playwright browsers)
|
||||
// 4. Copies .env.example to .env.local if not exists
|
||||
// 5. --self-heal flag: runs build check + auto-generates missing modules
|
||||
//
|
||||
// All steps are cross-platform (Windows, Mac, Linux).
|
||||
// Uses execSync for simplicity since each step blocks the next.
|
||||
|
||||
import { execSync } from "node:child_process"
|
||||
import { existsSync, copyFileSync } from "node:fs"
|
||||
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
|
||||
import { platform } from "node:os"
|
||||
import { resolve, dirname } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const SEP = platform() === "win32" ? "&" : ";"
|
||||
const SELF = dirname(fileURLToPath(import.meta.url))
|
||||
const ROOT = resolve(SELF, "..")
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
// Auto-detect Python executable (python vs python3)
|
||||
function detectPython() {
|
||||
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||
for (const cmd of candidates) {
|
||||
@@ -28,7 +33,6 @@ function detectPython() {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Auto-detect pip (pip vs pip3), fall back to python -m pip
|
||||
function detectPip(python) {
|
||||
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
|
||||
for (const cmd of candidates) {
|
||||
@@ -40,43 +44,167 @@ function detectPip(python) {
|
||||
return `${python} -m pip`
|
||||
}
|
||||
|
||||
const PY = detectPython()
|
||||
const PIP = detectPip(PY)
|
||||
|
||||
function run(cmd, label) {
|
||||
function run(cmd, label, opts = {}) {
|
||||
console.log(`\n── ${label} ──`)
|
||||
try {
|
||||
execSync(cmd, { stdio: "inherit", timeout: 120000 })
|
||||
execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
|
||||
} catch (e) {
|
||||
if (opts.optional) {
|
||||
console.log(` ~ Skipped (${e.message})`)
|
||||
return false
|
||||
}
|
||||
console.error(` ✗ Failed: ${e.message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Self-Healing Module Registry ───────────────────────────────────
|
||||
|
||||
function loadRegistry() {
|
||||
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
|
||||
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
|
||||
|
||||
const builtInTemplates = {
|
||||
"data/stickers": { template: "stickers.ts" },
|
||||
"data/constants": { template: "constants.ts" },
|
||||
"lib/utils": { template: "utils.ts" },
|
||||
"types/index": { template: "types-index.ts" },
|
||||
"hooks/use-media": { template: "hooks/use-media.ts" },
|
||||
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
|
||||
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
|
||||
}
|
||||
|
||||
const builtInFallbacks = {
|
||||
"@/data/*": "data-generic.ts",
|
||||
"@/lib/*": "lib-generic.ts",
|
||||
"@/hooks/*": "hook-generic.ts",
|
||||
"@/types/*": "types-generic.ts",
|
||||
"@/components/*": "component-generic.tsx",
|
||||
"@/utils/*": "utils-generic.ts",
|
||||
}
|
||||
|
||||
let templates = { ...builtInTemplates }
|
||||
let fallbacks = { ...builtInFallbacks }
|
||||
|
||||
try {
|
||||
if (existsSync(registryPath)) {
|
||||
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
|
||||
templates = { ...templates, ...(custom.templates || {}) }
|
||||
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const templateCache = {}
|
||||
|
||||
function getTemplate(templateName) {
|
||||
if (templateCache[templateName]) return templateCache[templateName]
|
||||
const candidatePaths = [
|
||||
resolve(templatesDir, templateName),
|
||||
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
|
||||
]
|
||||
for (const p of candidatePaths) {
|
||||
if (existsSync(p)) {
|
||||
const content = readFileSync(p, "utf-8")
|
||||
templateCache[templateName] = content
|
||||
return content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function matchFallback(internalPath) {
|
||||
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
|
||||
const regexStr = "^" + pattern
|
||||
.replace(/\//g, "\\/").replace(/\./g, "\\.")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
|
||||
const regex = new RegExp(regexStr)
|
||||
if (regex.test(internalPath)) return templateFile
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateModule(internalPath) {
|
||||
let templateFile = templates[internalPath]?.template
|
||||
if (!templateFile) templateFile = matchFallback(internalPath)
|
||||
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
|
||||
|
||||
const content = getTemplate(templateFile)
|
||||
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
|
||||
|
||||
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
|
||||
const filePath = resolve(ROOT, "src", internalPath + ext)
|
||||
mkdirSync(dirname(filePath), { recursive: true })
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
return { success: true, filePath, message: `Generated @/${internalPath}` }
|
||||
}
|
||||
|
||||
return { generateModule }
|
||||
}
|
||||
|
||||
function parseMissingModules(buildOutput) {
|
||||
const results = []
|
||||
const seen = new Set()
|
||||
|
||||
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
|
||||
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
|
||||
let match
|
||||
while ((match = errorRegex.exec(buildOutput)) !== null) {
|
||||
const importPath = match[1]
|
||||
if (!seen.has(importPath)) {
|
||||
seen.add(importPath)
|
||||
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
|
||||
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
|
||||
results.push({ importPath, isInternal, internalPath })
|
||||
}
|
||||
}
|
||||
|
||||
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
|
||||
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
|
||||
while ((match = webpackRegex.exec(buildOutput)) !== null) {
|
||||
const importPath = match[1]
|
||||
if (!seen.has(importPath)) {
|
||||
seen.add(importPath)
|
||||
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
|
||||
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
|
||||
results.push({ importPath, isInternal, internalPath })
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────
|
||||
|
||||
const PY = detectPython()
|
||||
const PIP = detectPip(PY)
|
||||
|
||||
console.log("=== CoastIT CRM Setup ===\n")
|
||||
|
||||
// 1. Node dependencies
|
||||
run("npm install", "Installing Node.js dependencies")
|
||||
|
||||
// 2. Python dependencies (run from browser-use-service directory)
|
||||
// 2. Python dependencies
|
||||
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
|
||||
|
||||
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback)
|
||||
// 3. Playwright browsers
|
||||
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
|
||||
|
||||
// 4. .env file — create from template if it doesn't exist
|
||||
if (!existsSync(".env.local")) {
|
||||
// 4. .env file
|
||||
if (!existsSync(resolve(ROOT, ".env.local"))) {
|
||||
console.log("\n── Creating .env.local ──")
|
||||
copyFileSync(".env.example", ".env.local")
|
||||
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
|
||||
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
|
||||
} else {
|
||||
console.log("\n── .env.local already exists, skipping ──")
|
||||
}
|
||||
|
||||
// 5. Remaining manual steps
|
||||
console.log("\n── Next steps ──")
|
||||
// 5. Final summary
|
||||
console.log("\n── Final Steps ──")
|
||||
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
|
||||
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
|
||||
console.log(" 3. Edit .env.local with your settings")
|
||||
console.log(" 4. Run: npm run dev")
|
||||
console.log(" 4. Run: npm run db:migrate (apply database migrations)")
|
||||
console.log(" 5. Run: npm run dev")
|
||||
console.log("\n=== Setup complete! ===")
|
||||
|
||||
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
|
||||
import pg from "pg"
|
||||
|
||||
const PORT = process.env.SIGNALING_PORT || 3007
|
||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
||||
const rawSecret = process.env.JWT_SECRET
|
||||
if (!rawSecret) throw new Error("JWT_SECRET environment variable is required")
|
||||
const JWT_SECRET = new TextEncoder().encode(rawSecret)
|
||||
const DATABASE_URL = process.env.DATABASE_URL
|
||||
if (!DATABASE_URL) throw new Error("DATABASE_URL environment variable is required")
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||
|
||||
|
||||
@@ -1,85 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useCallback } from "react"
|
||||
import { AIChat } from "@/components/ai/ai-chat"
|
||||
import { useState, useCallback, useRef } from "react"
|
||||
import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
|
||||
import { JobSelector } from "@/components/ai/job-selector"
|
||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
||||
import { Bot, ChevronRight } from "lucide-react"
|
||||
|
||||
export default function AIAssistantPage() {
|
||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
||||
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
|
||||
const [searching, setSearching] = useState(false)
|
||||
const aiChatRef = useRef<AIChatHandle | null>(null)
|
||||
|
||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||
setSelectedJob(job)
|
||||
}, [])
|
||||
|
||||
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
|
||||
setSearching(true)
|
||||
const keyword = job.keywords?.[0] || job.job_title
|
||||
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 360000)
|
||||
const statusId = setTimeout(() => {
|
||||
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
|
||||
}, 45000)
|
||||
|
||||
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
|
||||
|
||||
try {
|
||||
const res = await fetch(`${scrapBase}/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout(statusId)
|
||||
const data = await res.json()
|
||||
if (data.success && data.leads?.length > 0) {
|
||||
const leadLines = data.leads
|
||||
.filter(Boolean)
|
||||
.map((lead: Record<string, string>, i: number) =>
|
||||
`**${i + 1}.** ${lead?.author || "Unknown"}\n> ${(lead?.content || "").slice(0, 300)}\n> 🔗 ${lead?.url || "(no link available)"}`
|
||||
)
|
||||
const leadsText = leadLines.join("\n\n")
|
||||
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
|
||||
} else {
|
||||
const reason = data.error || data.flag_reason || "No leads found this time"
|
||||
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout(statusId)
|
||||
const msg = err.name === "AbortError"
|
||||
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service."
|
||||
: `❌ ${err instanceof Error ? err.message : "Search failed"}`
|
||||
aiChatRef.current?.addAssistantMessage(msg)
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRecentPromptClick = useCallback((prompt: string) => {
|
||||
aiChatRef.current?.fillInput(prompt)
|
||||
}, [])
|
||||
|
||||
const handleMessageSent = useCallback((msg: string) => {
|
||||
setRecentPrompts((prev) => {
|
||||
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
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 className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
|
||||
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
|
||||
<Bot className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
|
||||
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
||||
<span className="text-[#22c55e] text-xs font-medium">Online</span>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-border" />
|
||||
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
|
||||
<span className="text-muted-foreground text-xs">Local AI Model</span>
|
||||
</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>
|
||||
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
||||
</div>
|
||||
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||
</div>
|
||||
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
||||
{selectedJob && (
|
||||
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
|
||||
<span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{selectedJob.keywords.map((kw, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote,
|
||||
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||
ArrowUpRight, Zap, StickyNote, Globe,
|
||||
} from "lucide-react"
|
||||
|
||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||
meeting: Users,
|
||||
call: Phone,
|
||||
chat: Video,
|
||||
follow_up: Clock,
|
||||
demo: Calendar,
|
||||
other: Calendar,
|
||||
website_creation: Globe,
|
||||
}
|
||||
|
||||
function eventVars(type: EventType) {
|
||||
@@ -85,14 +82,20 @@ export default function CalendarPage() {
|
||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
||||
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
|
||||
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
|
||||
const [formTitle, setFormTitle] = useState("")
|
||||
const [formDescription, setFormDescription] = useState("")
|
||||
const [formType, setFormType] = useState<EventType>("meeting")
|
||||
const [formType, setFormType] = useState<EventType>("website_creation")
|
||||
const [formDate, setFormDate] = useState("")
|
||||
const [formTime, setFormTime] = useState("")
|
||||
const [formDuration, setFormDuration] = useState("30")
|
||||
const [formParticipantId, setFormParticipantId] = useState("")
|
||||
const [formDeveloperId, setFormDeveloperId] = useState("")
|
||||
const [formLeadId, setFormLeadId] = useState("")
|
||||
const [formClientName, setFormClientName] = useState("")
|
||||
const [formClientEmail, setFormClientEmail] = useState("")
|
||||
const [formClientPhone, setFormClientPhone] = useState("")
|
||||
const [formSaving, setFormSaving] = useState(false)
|
||||
const [editNotes, setEditNotes] = useState(false)
|
||||
const [notesText, setNotesText] = useState("")
|
||||
@@ -124,6 +127,10 @@ export default function CalendarPage() {
|
||||
.then((r) => r.json())
|
||||
.then((data) => setUsers(data.users || []))
|
||||
.catch(() => {})
|
||||
fetch("/api/leads?limit=200")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
|
||||
.catch(() => {})
|
||||
}, [user, router])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,7 +165,7 @@ export default function CalendarPage() {
|
||||
|
||||
const getEventsForDay = useCallback((day: number) => {
|
||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
||||
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
|
||||
}, [events, currentYear, currentMonth])
|
||||
|
||||
const isToday = (day: number) => {
|
||||
@@ -169,9 +176,14 @@ export default function CalendarPage() {
|
||||
setEditingEvent(null)
|
||||
setFormTitle("")
|
||||
setFormDescription("")
|
||||
setFormType("meeting")
|
||||
setFormType("website_creation")
|
||||
setFormDuration("30")
|
||||
setFormParticipantId("")
|
||||
setFormDeveloperId("")
|
||||
setFormLeadId("")
|
||||
setFormClientName("")
|
||||
setFormClientEmail("")
|
||||
setFormClientPhone("")
|
||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||
setFormDate(d.toISOString().split("T")[0])
|
||||
setFormTime("10:00")
|
||||
@@ -184,37 +196,58 @@ export default function CalendarPage() {
|
||||
setFormDescription(event.description || "")
|
||||
setFormType(event.eventType)
|
||||
setFormDuration(String(event.durationMinutes || 30))
|
||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
||||
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
|
||||
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
|
||||
setFormParticipantId(event.participantId || "")
|
||||
setFormDeveloperId(event.developerId || "")
|
||||
setFormLeadId(event.leadId || "")
|
||||
setFormClientName(event.clientName || "")
|
||||
setFormClientEmail(event.clientEmail || "")
|
||||
setFormClientPhone(event.clientPhone || "")
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formTitle.trim() || !formDate || !formTime) {
|
||||
toast.error("Title, date, and time are required")
|
||||
if (!formTitle.trim()) {
|
||||
toast.error("Title is required")
|
||||
return
|
||||
}
|
||||
if (formType !== "website_creation" && (!formDate || !formTime)) {
|
||||
toast.error("Date and time are required for this event type")
|
||||
return
|
||||
}
|
||||
|
||||
setFormSaving(true)
|
||||
try {
|
||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
title: formTitle.trim(),
|
||||
description: formDescription.trim() || null,
|
||||
eventType: formType,
|
||||
startTime,
|
||||
endTime,
|
||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
||||
}
|
||||
|
||||
let startTime: string | undefined
|
||||
if (formType !== "website_creation") {
|
||||
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||
let endTime = null
|
||||
if (formDuration) {
|
||||
const end = new Date(startTime)
|
||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||
endTime = end.toISOString()
|
||||
}
|
||||
body.startTime = startTime
|
||||
body.endTime = endTime
|
||||
body.durationMinutes = formDuration ? parseInt(formDuration) : null
|
||||
} else if (formDate) {
|
||||
startTime = new Date(`${formDate}T00:00:00`).toISOString()
|
||||
body.startTime = startTime
|
||||
}
|
||||
|
||||
if (formClientName) body.clientName = formClientName
|
||||
if (formClientEmail) body.clientEmail = formClientEmail
|
||||
if (formClientPhone) body.clientPhone = formClientPhone
|
||||
if (formParticipantId) body.participantId = formParticipantId
|
||||
if (formDeveloperId) body.developerId = formDeveloperId
|
||||
if (formLeadId) body.leadId = formLeadId
|
||||
|
||||
let res
|
||||
if (editingEvent) {
|
||||
@@ -235,11 +268,13 @@ export default function CalendarPage() {
|
||||
|
||||
const data = await res.json()
|
||||
if (editingEvent) {
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||
toast.success("Event updated")
|
||||
} else {
|
||||
setEvents((prev) => [...prev, data.event])
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
if (startTime) {
|
||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||
}
|
||||
toast.success("Event created")
|
||||
}
|
||||
|
||||
@@ -260,7 +295,7 @@ export default function CalendarPage() {
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to update")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||
toast.success(`Event ${newStatus}`)
|
||||
setDetailEvent(null)
|
||||
} catch {
|
||||
@@ -279,7 +314,7 @@ export default function CalendarPage() {
|
||||
})
|
||||
if (!res.ok) throw new Error("Failed to save")
|
||||
const data = await res.json()
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||
if (detailEvent && detailEvent.id === event.id) {
|
||||
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||
}
|
||||
@@ -579,7 +614,7 @@ export default function CalendarPage() {
|
||||
|
||||
{/* Create/Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
@@ -608,11 +643,28 @@ export default function CalendarPage() {
|
||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
{formType === "website_creation" && (
|
||||
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
|
||||
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
|
||||
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
|
||||
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||
@@ -621,19 +673,16 @@ export default function CalendarPage() {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="meeting">Meeting</SelectItem>
|
||||
<SelectItem value="website_creation">Website Creation</SelectItem>
|
||||
<SelectItem value="call">Call</SelectItem>
|
||||
<SelectItem value="chat">Video Chat</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="demo">Demo</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||
<SelectTrigger id="duration" className="h-10">
|
||||
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -663,6 +712,38 @@ export default function CalendarPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
|
||||
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="developer" className="h-10">
|
||||
<SelectValue placeholder="Select a developer…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name} {u.role ? `(${u.role})` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
|
||||
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
|
||||
<SelectTrigger id="lead" className="h-10">
|
||||
<SelectValue placeholder="Select a client lead…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">None</SelectItem>
|
||||
{leads.map((l) => (
|
||||
<SelectItem key={l.id} value={l.id}>
|
||||
{l.contactName}{l.companyName ? ` — ${l.companyName}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
||||
@@ -690,7 +771,7 @@ export default function CalendarPage() {
|
||||
{/* Event Detail Dialog */}
|
||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||
{detailEvent && (
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||
@@ -711,22 +792,29 @@ export default function CalendarPage() {
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Date / Time */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
{detailEvent.startTime ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
Date
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
Time
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
|
||||
<p className="text-sm text-muted-foreground/50 italic">No scheduled time — website creation project</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Duration + Type */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -751,11 +839,26 @@ export default function CalendarPage() {
|
||||
{/* Description */}
|
||||
{detailEvent.description && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Client details */}
|
||||
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
Client Details
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
|
||||
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
|
||||
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked lead */}
|
||||
{detailEvent.lead && (
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
@@ -802,6 +905,32 @@ export default function CalendarPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Developer */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
|
||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
|
||||
</p>
|
||||
{detailEvent.developer ? (
|
||||
<p className="text-sm font-bold flex items-center gap-2">
|
||||
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
|
||||
{detailEvent.developer.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{detailEvent.developer.name}</span>
|
||||
{detailEvent.developer.role && (
|
||||
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
|
||||
)}
|
||||
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
|
||||
Assigned by {detailEvent.creator?.name || "Unknown"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Participant Notes (editable by both parties) */}
|
||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -836,9 +965,15 @@ export default function CalendarPage() {
|
||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||
{detailEvent.status === "scheduled" && (
|
||||
<>
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
{detailEvent.developerId === user?.id ? (
|
||||
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||
} from "lucide-react"
|
||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
DialogFooter, DialogClose,
|
||||
@@ -1041,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
|
||||
{stickerData.emoji ? (
|
||||
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||
) : (
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||
)}
|
||||
</div>
|
||||
) : voiceUrl ? (
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { DashboardStats } from "@/types"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { websiteTheme } = useWebsiteTheme()
|
||||
const [period, setPeriod] = useState("6months")
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||
@@ -84,7 +86,7 @@ export default function DashboardPage() {
|
||||
</PageHeader>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
{stats
|
||||
@@ -95,7 +97,7 @@ export default function DashboardPage() {
|
||||
}
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||
@@ -104,12 +106,13 @@ export default function DashboardPage() {
|
||||
|
||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||
</div>
|
||||
{/* Daily Bugle watermark */}
|
||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||
DAILY BUGLE
|
||||
</span>
|
||||
</div>
|
||||
{websiteTheme === "spidey" && (
|
||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||
DAILY BUGLE
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (!GIPHY_API_KEY) {
|
||||
return NextResponse.json({ error: "GIPHY API key not configured" }, { status: 500 })
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get("type") || "trending"
|
||||
const query = searchParams.get("q") || ""
|
||||
const offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||
|
||||
let url: string
|
||||
if (type === "search" && query) {
|
||||
url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}&rating=g`
|
||||
} else {
|
||||
url = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${offset}&rating=g`
|
||||
}
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text()
|
||||
console.error("GIPHY API error:", res.status, errBody)
|
||||
return NextResponse.json({ error: `GIPHY API error (${res.status}): ${errBody}` }, { status: res.status })
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const gifs = (data.data || []).map((gif: any) => ({
|
||||
id: gif.id,
|
||||
title: gif.title,
|
||||
url: gif.images?.original?.url || "",
|
||||
previewUrl: gif.images?.fixed_width?.url || "",
|
||||
previewHeight: gif.images?.fixed_width?.height || 150,
|
||||
width: gif.images?.original?.width || 0,
|
||||
height: gif.images?.original?.height || 0,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
gifs,
|
||||
pagination: data.pagination || { total_count: 0, count: gifs.length, offset },
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("GIPHY proxy error:", error)
|
||||
return NextResponse.json({ error: error.message || "Internal server error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { cookies } from "next/headers"
|
||||
|
||||
export async function GET() {
|
||||
const cookieStore = await cookies()
|
||||
const token = cookieStore.get("session")?.value
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No session" }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ token })
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { NextRequest } from "next/server"
|
||||
import {
|
||||
comparePassword,
|
||||
getUserByEmail,
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
SESSION_COOKIE,
|
||||
} from "@/lib/auth"
|
||||
|
||||
|
||||
function jsonResponse(data: unknown, status: number) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { email, username, password } = await request.json()
|
||||
@@ -20,16 +28,16 @@ export async function POST(request: NextRequest) {
|
||||
const credential = email || username
|
||||
|
||||
if (!credential || !password) {
|
||||
return NextResponse.json(
|
||||
return jsonResponse(
|
||||
{ error: "Email/Username and password are required." },
|
||||
{ status: 400 }
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||
return NextResponse.json(
|
||||
return jsonResponse(
|
||||
{ error: "Credentials cannot be empty." },
|
||||
{ status: 400 }
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,9 +67,9 @@ export async function POST(request: NextRequest) {
|
||||
false,
|
||||
"User not found"
|
||||
)
|
||||
return NextResponse.json(
|
||||
return jsonResponse(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,9 +83,9 @@ export async function POST(request: NextRequest) {
|
||||
false,
|
||||
lockStatus.reason
|
||||
)
|
||||
return NextResponse.json(
|
||||
return jsonResponse(
|
||||
{ error: lockStatus.reason },
|
||||
{ status: 423 }
|
||||
423
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,9 +100,9 @@ export async function POST(request: NextRequest) {
|
||||
false,
|
||||
"Invalid password"
|
||||
)
|
||||
return NextResponse.json(
|
||||
return jsonResponse(
|
||||
{ error: "Invalid email/username or password." },
|
||||
{ status: 401 }
|
||||
401
|
||||
)
|
||||
}
|
||||
|
||||
@@ -112,19 +120,20 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const user = mapDbUserToSessionUser(dbUser)
|
||||
|
||||
const response = NextResponse.json({ user }, { status: 200 })
|
||||
response.cookies.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
|
||||
|
||||
return new Response(JSON.stringify({ user }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": cookieStr,
|
||||
},
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("Login error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Authentication service unavailable." },
|
||||
{ status: 503 }
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Authentication service unavailable." }),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { SESSION_COOKIE } from "@/lib/auth"
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const response = NextResponse.json({ success: true }, { status: 200 })
|
||||
response.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
|
||||
},
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("Logout error:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Logout failed." },
|
||||
{ status: 500 }
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Logout failed." }),
|
||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { avatarSvgUrl } from "@/lib/avatar"
|
||||
|
||||
export async function GET() {
|
||||
@@ -18,13 +18,18 @@ export async function GET() {
|
||||
u.email AS other_user_email,
|
||||
u.phone AS other_user_phone,
|
||||
u.avatar_url AS other_user_avatar_url,
|
||||
(SELECT content FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT created_at FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
||||
lm.last_message_data->>'content' AS last_message,
|
||||
(lm.last_message_data->>'created_at')::timestamptz 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
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT jsonb_build_object('content', content, 'created_at', created_at) AS last_message_data
|
||||
FROM messages WHERE conversation_id = c.id AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) lm ON true
|
||||
WHERE c.id IN (
|
||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||
)
|
||||
@@ -80,23 +85,27 @@ export async function POST(request: NextRequest) {
|
||||
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
|
||||
const result = await transaction(async (client) => {
|
||||
const convResult = await client.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],
|
||||
)
|
||||
await client.query(
|
||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||
[conversationId, user.id, userId],
|
||||
)
|
||||
|
||||
const otherUser = await query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
const otherResult = await client.query(
|
||||
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||
FROM users WHERE id = $1`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const other = otherUser.rows[0]
|
||||
return { conversationId, other: otherResult.rows[0] }
|
||||
})
|
||||
|
||||
const { conversationId, other } = result
|
||||
|
||||
return NextResponse.json({
|
||||
conversation: {
|
||||
|
||||
+111
-78
@@ -48,74 +48,22 @@ function stageToStatus(name: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
const stageStatusSql = `
|
||||
CASE
|
||||
WHEN ls.name = 'New' THEN 'open'
|
||||
WHEN ls.name = 'Contacted' THEN 'contacted'
|
||||
WHEN ls.name IN ('Qualified', 'Interested', 'Demo Scheduled', 'Negotiation') THEN 'pending'
|
||||
WHEN ls.name = 'Closed Won' THEN 'closed'
|
||||
WHEN ls.name = 'Closed Lost' THEN 'ignored'
|
||||
ELSE 'open'
|
||||
END`
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const user = await getSessionUser()
|
||||
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
|
||||
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 ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||
|
||||
const currentCounts = countStatuses(currentLeads)
|
||||
const prevCounts = countStatuses(prevLeads)
|
||||
// Status counts for current period (SQL aggregation)
|
||||
const countSql = `
|
||||
SELECT ${stageStatusSql} AS status, COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $1 AND l.created_at <= $2
|
||||
${ownerFilter}
|
||||
GROUP BY ${stageStatusSql}`
|
||||
|
||||
const totalLeads = currentLeads.length
|
||||
const currentCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const prevCountRows = await query(
|
||||
countSql,
|
||||
isAdmin
|
||||
? [prevRange.start.toISOString(), prevRange.end.toISOString()]
|
||||
: [prevRange.start.toISOString(), prevRange.end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
function rowsToCounts(rows: any[]) {
|
||||
const counts = { open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
for (const r of rows) {
|
||||
const s = r.status as keyof typeof counts
|
||||
if (s in counts) counts[s] = parseInt(r.count, 10)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
const currentCounts = rowsToCounts(currentCountRows.rows)
|
||||
const prevCounts = rowsToCounts(prevCountRows.rows)
|
||||
|
||||
const totalLeads = currentCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const prevTotal = prevCountRows.rows.reduce((sum: number, r: any) => sum + parseInt(r.count, 10), 0)
|
||||
const closedLeads = currentCounts.closed
|
||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
||||
|
||||
const mappedLeads = currentLeads.map((r: any) => ({
|
||||
// Monthly/weekly breakdown via date_trunc
|
||||
const isMonthly = period === "6months" || period === "12months"
|
||||
const truncUnit = isMonthly ? "month" : "day"
|
||||
const breakdownSql = `
|
||||
SELECT DATE_TRUNC($1, l.created_at) AS period_start,
|
||||
${stageStatusSql} AS status,
|
||||
COUNT(*) AS count
|
||||
FROM leads l
|
||||
JOIN lead_stages ls ON ls.id = l.stage_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
AND l.created_at >= $2 AND l.created_at <= $3
|
||||
${isAdmin ? "" : "AND l.assigned_to = $4"}
|
||||
GROUP BY DATE_TRUNC($1, l.created_at), ${stageStatusSql}
|
||||
ORDER BY period_start ASC`
|
||||
const breakdownParams = isAdmin
|
||||
? [truncUnit, start.toISOString(), end.toISOString()]
|
||||
: [truncUnit, start.toISOString(), end.toISOString(), user.id]
|
||||
const breakdownResult = await query(breakdownSql, breakdownParams)
|
||||
|
||||
// Build monthly breakdown from aggregated data
|
||||
const breakdownMap: Record<string, { label: string; total: number; open: number; contacted: number; pending: number; closed: number; ignored: number }> = {}
|
||||
for (const r of breakdownResult.rows) {
|
||||
const d = new Date(r.period_start)
|
||||
const label = isMonthly
|
||||
? d.toLocaleDateString("en-US", { month: "short", year: "2-digit" })
|
||||
: d.toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
||||
if (!breakdownMap[label]) {
|
||||
breakdownMap[label] = { label, total: 0, open: 0, contacted: 0, pending: 0, closed: 0, ignored: 0 }
|
||||
}
|
||||
const count = parseInt(r.count, 10)
|
||||
breakdownMap[label].total += count
|
||||
const statusKey = r.status as 'open' | 'contacted' | 'pending' | 'closed' | 'ignored'
|
||||
breakdownMap[label][statusKey] = count
|
||||
}
|
||||
const monthlyBreakdown = Object.values(breakdownMap)
|
||||
|
||||
// Recent 10 leads
|
||||
const recentSql = `
|
||||
SELECT l.id, l.created_at, l.company_name, l.contact_name, l.email, l.phone,
|
||||
l.notes, l.assigned_to, l.score,
|
||||
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
|
||||
${ownerFilter}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT 10`
|
||||
const recentResult = await query(
|
||||
recentSql,
|
||||
isAdmin
|
||||
? [start.toISOString(), end.toISOString()]
|
||||
: [start.toISOString(), end.toISOString(), user.id],
|
||||
)
|
||||
|
||||
const recentLeads = recentResult.rows.map((r: any) => ({
|
||||
id: r.id,
|
||||
companyName: r.company_name || "",
|
||||
contactName: r.contact_name,
|
||||
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
|
||||
phone: r.phone || "",
|
||||
source: "",
|
||||
description: r.notes || "",
|
||||
status: r.status,
|
||||
status: stageToStatus(r.stage_name),
|
||||
assignedUserId: r.assigned_to,
|
||||
assignedUser: r.assigned_to ? {
|
||||
id: r.user_id,
|
||||
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
|
||||
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),
|
||||
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||
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),
|
||||
prevTotal > 0 ? Math.round((prevCounts.closed / prevTotal) * 100) : 0),
|
||||
}
|
||||
|
||||
const stats = {
|
||||
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
|
||||
ignoredLeads: currentCounts.ignored,
|
||||
conversionRate,
|
||||
monthlyBreakdown,
|
||||
leadsPerMonth: monthlyBreakdown.map((m: any) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
leadsPerMonth: monthlyBreakdown.map((m) => ({ label: m.label, leads: m.total, closed: m.closed })),
|
||||
trends,
|
||||
recentLeads: mappedLeads.slice(0, 10),
|
||||
recentLeads,
|
||||
statusDistribution: [
|
||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||
|
||||
@@ -8,8 +8,11 @@ export async function GET() {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT u.id, u.first_name, u.last_name, u.email
|
||||
`SELECT u.id, u.first_name, u.last_name, u.email,
|
||||
ur.role_id, r.name AS role_name, r.display_name AS role_display
|
||||
FROM users u
|
||||
LEFT JOIN user_roles ur ON ur.user_id = u.id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
WHERE u.deleted_at IS NULL AND u.id != $1
|
||||
ORDER BY u.first_name ASC`,
|
||||
[user.id],
|
||||
@@ -19,6 +22,7 @@ export async function GET() {
|
||||
id: r.id,
|
||||
name: `${r.first_name} ${r.last_name}`,
|
||||
email: r.email,
|
||||
role: r.role_display || r.role_name || "",
|
||||
}))
|
||||
|
||||
return NextResponse.json({ users })
|
||||
|
||||
@@ -9,11 +9,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const { id } = await params
|
||||
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
|
||||
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
|
||||
|
||||
const existing = await query(
|
||||
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status,
|
||||
`SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
|
||||
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
|
||||
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
|
||||
FROM scheduled_events e
|
||||
@@ -31,8 +31,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
const old = existing.rows[0]
|
||||
const isCreator = old.user_id === user.id
|
||||
const isParticipant = old.participant_id === user.id
|
||||
const isDeveloper = old.developer_id === user.id
|
||||
|
||||
if (!isCreator && !isParticipant) {
|
||||
if (!isCreator && !isParticipant && !isDeveloper) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
@@ -42,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
(startTime !== undefined && startTime !== old.start_time) ||
|
||||
(endTime !== undefined && endTime !== old.end_time) ||
|
||||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
|
||||
(participantId !== undefined && participantId !== old.participant_id)
|
||||
(participantId !== undefined && participantId !== old.participant_id) ||
|
||||
(developerId !== undefined && developerId !== old.developer_id)
|
||||
)) {
|
||||
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
|
||||
}
|
||||
@@ -53,14 +55,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
description = COALESCE($2, description),
|
||||
participant_notes = COALESCE($3, participant_notes),
|
||||
event_type = COALESCE($4, event_type),
|
||||
start_time = COALESCE($5, start_time),
|
||||
end_time = COALESCE($6, end_time),
|
||||
duration_minutes = COALESCE($7, duration_minutes),
|
||||
start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
|
||||
end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
|
||||
duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
|
||||
status = COALESCE($8, status),
|
||||
participant_id = COALESCE($9, participant_id),
|
||||
developer_id = COALESCE($10, developer_id),
|
||||
client_name = COALESCE($11, client_name),
|
||||
client_email = COALESCE($12, client_email),
|
||||
client_phone = COALESCE($13, client_phone),
|
||||
updated_at = NOW()
|
||||
WHERE id = $10
|
||||
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
|
||||
WHERE id = $14
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
title || null,
|
||||
description ?? null,
|
||||
@@ -71,6 +77,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
durationMinutes ?? null,
|
||||
status || null,
|
||||
participantId ?? null,
|
||||
developerId ?? null,
|
||||
clientName ?? null,
|
||||
clientEmail ?? null,
|
||||
clientPhone ?? null,
|
||||
id,
|
||||
],
|
||||
user.id,
|
||||
@@ -78,6 +88,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
|
||||
const r = result.rows[0]
|
||||
|
||||
// Auto-close lead when developer marks event as completed
|
||||
if (status === "completed" && r.lead_id) {
|
||||
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await query(
|
||||
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
|
||||
[stageResult.rows[0].id, r.lead_id],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
|
||||
r.title !== old.title || r.event_type !== old.event_type ||
|
||||
r.status !== old.status
|
||||
@@ -108,12 +129,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
)
|
||||
const creatorInfo = creatorResult.rows[0]
|
||||
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
|
||||
const developerInfo = old.developer_id ? { id: old.developer_id, name: "", email: "" } : null
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
developerId: r.developer_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
@@ -126,7 +149,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
status: r.status,
|
||||
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
|
||||
participant: participantInfo,
|
||||
developer: developerInfo,
|
||||
lead: null,
|
||||
clientName: r.client_name || null,
|
||||
clientEmail: r.client_email || null,
|
||||
clientPhone: r.client_phone || null,
|
||||
createdAt: r.created_at,
|
||||
},
|
||||
})
|
||||
|
||||
+106
-62
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import { query, transaction } from "@/lib/db"
|
||||
import { sendEventConfirmation } from "@/lib/email"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@@ -13,27 +13,33 @@ export async function GET(request: NextRequest) {
|
||||
const end = searchParams.get("end")
|
||||
const status = searchParams.get("status")
|
||||
|
||||
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
|
||||
let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
|
||||
e.title, e.description, e.participant_notes, e.event_type,
|
||||
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
|
||||
e.client_name, e.client_email, e.client_phone,
|
||||
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
|
||||
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
|
||||
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
|
||||
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
|
||||
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
|
||||
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_role_display,
|
||||
l.id AS l_id, l.company_name, l.contact_name
|
||||
FROM scheduled_events e
|
||||
JOIN users u ON u.id = e.user_id
|
||||
LEFT JOIN users p ON p.id = e.participant_id
|
||||
LEFT JOIN users d ON d.id = e.developer_id
|
||||
LEFT JOIN leads l ON l.id = e.lead_id
|
||||
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
|
||||
LEFT JOIN roles r ON r.id = ur.role_id
|
||||
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
|
||||
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
|
||||
LEFT JOIN roles pr2 ON pr2.id = pr.role_id
|
||||
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
|
||||
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
|
||||
const params: unknown[] = []
|
||||
let idx = 1
|
||||
|
||||
if (user.role !== "super_admin") {
|
||||
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
|
||||
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
|
||||
params.push(user.id)
|
||||
idx++
|
||||
} else {
|
||||
@@ -41,13 +47,13 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (start) {
|
||||
sql += ` AND e.start_time >= $${idx}`
|
||||
sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
|
||||
params.push(start)
|
||||
idx++
|
||||
}
|
||||
|
||||
if (end) {
|
||||
sql += ` AND e.start_time <= $${idx}`
|
||||
sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
|
||||
params.push(end)
|
||||
idx++
|
||||
}
|
||||
@@ -66,6 +72,7 @@ export async function GET(request: NextRequest) {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
developerId: r.developer_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
@@ -78,7 +85,11 @@ export async function GET(request: NextRequest) {
|
||||
status: r.status,
|
||||
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
|
||||
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
|
||||
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
|
||||
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
|
||||
clientName: r.client_name || null,
|
||||
clientEmail: r.client_email || null,
|
||||
clientPhone: r.client_phone || null,
|
||||
createdAt: r.created_at,
|
||||
}))
|
||||
|
||||
@@ -95,58 +106,98 @@ export async function POST(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const {
|
||||
participantId, leadId, conversationId,
|
||||
participantId, developerId, leadId, conversationId,
|
||||
title, description, eventType,
|
||||
startTime, endTime, durationMinutes,
|
||||
clientName, clientEmail, clientPhone,
|
||||
} = await request.json()
|
||||
|
||||
if (!title || !startTime) {
|
||||
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "Title is required" }, { status: 400 })
|
||||
}
|
||||
if (eventType !== "website_creation" && !startTime) {
|
||||
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
|
||||
[
|
||||
user.id,
|
||||
participantId || null,
|
||||
leadId || null,
|
||||
conversationId || null,
|
||||
title,
|
||||
description || null,
|
||||
eventType || "meeting",
|
||||
startTime,
|
||||
endTime || null,
|
||||
durationMinutes || null,
|
||||
],
|
||||
user.id,
|
||||
)
|
||||
|
||||
const r = result.rows[0]
|
||||
|
||||
if (participantId && participantId !== user.id) {
|
||||
await query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
// Single transaction for all DB operations
|
||||
const result = await transaction(async (client) => {
|
||||
// 1. Insert event
|
||||
const ins = await client.query(
|
||||
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
|
||||
[
|
||||
participantId,
|
||||
"event_scheduled",
|
||||
`Meeting scheduled: ${title}`,
|
||||
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
`/calendar`,
|
||||
r.id,
|
||||
"scheduled_event",
|
||||
user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
|
||||
title, description || null, eventType || "website_creation", startTime || null,
|
||||
eventType === "website_creation" ? null : (endTime || null),
|
||||
eventType === "website_creation" ? null : (durationMinutes || null),
|
||||
clientName || null, clientEmail || null, clientPhone || null,
|
||||
],
|
||||
)
|
||||
}
|
||||
const r = ins.rows[0]
|
||||
|
||||
const participantResult = participantId ? await query(
|
||||
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
||||
[participantId],
|
||||
) : null
|
||||
const participant = participantResult?.rows[0] || null
|
||||
// 2. Auto-update lead stage if website creation event
|
||||
if (r.event_type === "website_creation" && leadId) {
|
||||
const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
|
||||
if (stageResult.rows.length > 0) {
|
||||
await client.query(
|
||||
"UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL",
|
||||
[stageResult.rows[0].id, leadId],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Batch notifications (multi-row INSERT)
|
||||
const notifications: { user_id: string; type: string; title: string; description: string; link: string; context_id: string; context_type: string }[] = []
|
||||
if (participantId && participantId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: participantId, type: "event_scheduled",
|
||||
title: `Meeting scheduled: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (developerId && developerId !== user.id) {
|
||||
notifications.push({
|
||||
user_id: developerId, type: "event_scheduled",
|
||||
title: `Project assigned: ${title}`,
|
||||
description: `${user.firstName} ${user.lastName} assigned a project to you`,
|
||||
link: "/calendar", context_id: r.id, context_type: "scheduled_event",
|
||||
})
|
||||
}
|
||||
if (notifications.length > 0) {
|
||||
const placeholders = notifications.map((_, i) =>
|
||||
`($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7})`
|
||||
).join(", ")
|
||||
const flatParams = notifications.flatMap(n => [n.user_id, n.type, n.title, n.description, n.link, n.context_id, n.context_type])
|
||||
await client.query(
|
||||
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) VALUES ${placeholders}`,
|
||||
flatParams,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fetch participant + developer in one query
|
||||
const idsToFetch = [participantId, developerId].filter(Boolean) as string[]
|
||||
let usersMap: Record<string, { id: string; first_name: string; last_name: string; email: string }> = {}
|
||||
if (idsToFetch.length > 0) {
|
||||
const userPlaceholders = idsToFetch.map((_, i) => `$${i + 1}`).join(", ")
|
||||
const userRows = await client.query(
|
||||
`SELECT id, first_name, last_name, email FROM users WHERE id IN (${userPlaceholders})`,
|
||||
idsToFetch,
|
||||
)
|
||||
for (const row of userRows.rows) {
|
||||
usersMap[row.id] = row
|
||||
}
|
||||
}
|
||||
|
||||
return { r, usersMap }
|
||||
}, user.id)
|
||||
|
||||
const { r, usersMap } = result
|
||||
const participant = participantId ? usersMap[participantId] || null : null
|
||||
const dev = developerId ? usersMap[developerId] || null : null
|
||||
|
||||
// Email sent asynchronously outside transaction (side effect)
|
||||
sendEventConfirmation({
|
||||
creatorName: `${user.firstName} ${user.lastName}`,
|
||||
creatorEmail: user.email,
|
||||
@@ -162,23 +213,16 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
return NextResponse.json({
|
||||
event: {
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
participantId: r.participant_id,
|
||||
leadId: r.lead_id,
|
||||
conversationId: r.conversation_id,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
participantNotes: r.participant_notes,
|
||||
eventType: r.event_type,
|
||||
startTime: r.start_time,
|
||||
endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes,
|
||||
status: r.status,
|
||||
id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
|
||||
leadId: r.lead_id, conversationId: r.conversation_id,
|
||||
title: r.title, description: r.description, participantNotes: r.participant_notes,
|
||||
eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
|
||||
durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
|
||||
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
|
||||
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
|
||||
participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
|
||||
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
|
||||
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||
createdAt: r.created_at,
|
||||
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||
},
|
||||
}, { status: 201 })
|
||||
} catch (error) {
|
||||
|
||||
+62
-25
@@ -1,8 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
|
||||
const TENOR_API_KEY = process.env.TENOR_API_KEY || ""
|
||||
const TENOR_BASE = "https://tenor.googleapis.com/v2"
|
||||
const GIPHY_API_KEY = process.env.GIPHY_API_KEY
|
||||
const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
@@ -12,41 +12,78 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const q = searchParams.get("q") || ""
|
||||
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||
const pos = searchParams.get("pos") || ""
|
||||
const pos = parseInt(searchParams.get("pos") || "0", 10) || 0
|
||||
|
||||
if (!TENOR_API_KEY) {
|
||||
if (!GIPHY_API_KEY) {
|
||||
console.error("Missing GIPHY_API_KEY environment variable")
|
||||
return NextResponse.json({ results: [], noKey: true })
|
||||
}
|
||||
|
||||
const endpoint = q
|
||||
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
||||
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
|
||||
let data: any
|
||||
|
||||
const url = pos ? `${endpoint}&pos=${pos}` : endpoint
|
||||
if (q) {
|
||||
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text()
|
||||
console.error("GIPHY API search error:", res.status, errBody)
|
||||
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
|
||||
}
|
||||
data = await res.json()
|
||||
} else {
|
||||
const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
|
||||
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
|
||||
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
|
||||
if (!res.ok) {
|
||||
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 })
|
||||
if (trendingRes.ok) {
|
||||
data = await trendingRes.json()
|
||||
if (!data.data?.length && pos === 0) {
|
||||
console.log("Trending returned no results, falling back to default search")
|
||||
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
|
||||
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
|
||||
if (!fallbackRes.ok) {
|
||||
const errBody = await fallbackRes.text()
|
||||
console.error("GIPHY fallback search error:", fallbackRes.status, errBody)
|
||||
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
|
||||
}
|
||||
data = await fallbackRes.json()
|
||||
}
|
||||
} else {
|
||||
const errBody = await trendingRes.text()
|
||||
console.error("GIPHY trending error:", trendingRes.status, errBody)
|
||||
console.log("Trending failed, falling back to default search")
|
||||
const fallbackUrl = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=funny&limit=${limit}&offset=${pos}&rating=g`
|
||||
const fallbackRes = await fetch(fallbackUrl, { signal: AbortSignal.timeout(8000) })
|
||||
if (!fallbackRes.ok) {
|
||||
const fallbackErr = await fallbackRes.text()
|
||||
console.error("GIPHY fallback search error:", fallbackRes.status, fallbackErr)
|
||||
return NextResponse.json({ results: [], error: `GIPHY error: ${fallbackErr}` }, { status: 502 })
|
||||
}
|
||||
data = await fallbackRes.json()
|
||||
}
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
const results = (data.results || []).map((item: any) => {
|
||||
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
|
||||
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
|
||||
const results = (data.data || []).map((item: any) => {
|
||||
const dims = item.images?.original
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title || "",
|
||||
url: media.url || "",
|
||||
previewUrl: preview.url || "",
|
||||
width: media.dims?.[0] || 200,
|
||||
height: media.dims?.[1] || 200,
|
||||
url: dims?.url || "",
|
||||
previewUrl: item.images?.fixed_width?.url || "",
|
||||
width: dims?.width ? parseInt(dims.width, 10) : 200,
|
||||
height: dims?.height ? parseInt(dims.height, 10) : 200,
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ results, next: data.next || "" })
|
||||
} catch (error) {
|
||||
console.error("GIF API error:", error)
|
||||
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 })
|
||||
const nextOffset = pos + results.length
|
||||
const total = data.pagination?.total_count || 0
|
||||
const hasMore = total ? nextOffset < total : results.length === limit
|
||||
const nextPos = hasMore ? String(nextOffset) : ""
|
||||
|
||||
return NextResponse.json({ results, next: nextPos })
|
||||
} catch (error: any) {
|
||||
console.error("GIF proxy error:", error)
|
||||
const message = error?.message === "Failed to fetch"
|
||||
? "Could not reach the GIF service."
|
||||
: "Failed to fetch GIFs."
|
||||
return NextResponse.json({ results: [], error: message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
import crypto from "crypto"
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { phone, token: clientToken } = await request.json()
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
if (sessionUser.role !== "super_admin") {
|
||||
return NextResponse.json({ error: "Only SUPER_ADMIN can generate invites." }, { status: 403 })
|
||||
}
|
||||
|
||||
const { phone } = await request.json()
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = clientToken || crypto.randomBytes(24).toString("hex")
|
||||
const ipAddress =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
||||
request.headers.get("x-real-ip") ||
|
||||
"127.0.0.1"
|
||||
|
||||
await setSessionContext(sessionUser.id, ipAddress)
|
||||
|
||||
const recentCount = await query(
|
||||
`SELECT COUNT(*) AS cnt FROM invites WHERE created_at > NOW() - INTERVAL '1 hour'`,
|
||||
)
|
||||
if (parseInt(recentCount.rows[0]?.cnt || "0", 10) >= 10) {
|
||||
return NextResponse.json({ error: "Rate limit exceeded. Try again later." }, { status: 429 })
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(24).toString("hex")
|
||||
await query(
|
||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||
[token, phone],
|
||||
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
|
||||
const inviteUrl = `${origin}/join/${token}`
|
||||
|
||||
return NextResponse.json({ token, inviteUrl })
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("Invite generation error:", error)
|
||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
+46
-23
@@ -43,47 +43,74 @@ export async function GET(request: NextRequest) {
|
||||
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
|
||||
|
||||
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||
|
||||
if (period !== "all") {
|
||||
const range = getPeriodDateRange(period)
|
||||
if (range) {
|
||||
sql += ` AND l.created_at >= $${paramIdx} AND l.created_at <= $${paramIdx + 1}`
|
||||
conditions.push(`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})`
|
||||
conditions.push(`(l.contact_name ILIKE $${paramIdx} OR l.company_name ILIKE $${paramIdx} OR l.email ILIKE $${paramIdx} OR l.phone ILIKE $${paramIdx})`)
|
||||
params.push(`%${search}%`)
|
||||
paramIdx++
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
||||
conditions.push(`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
|
||||
// Push status filter into SQL (avoid client-side filtering after pagination)
|
||||
const statusStageMap: Record<string, string[]> = {
|
||||
open: ["New"],
|
||||
contacted: ["Contacted"],
|
||||
pending: ["Qualified", "Interested", "Demo Scheduled", "Negotiation"],
|
||||
closed: ["Closed Won"],
|
||||
ignored: ["Closed Lost"],
|
||||
}
|
||||
if (status !== "all") {
|
||||
const stageNames = statusStageMap[status]
|
||||
if (stageNames) {
|
||||
const stagePlaceholders = stageNames.map((_, i) => `$${paramIdx + i}`)
|
||||
conditions.push(`ls.name IN (${stagePlaceholders.join(", ")})`)
|
||||
params.push(...stageNames)
|
||||
paramIdx += stageNames.length
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query(sql, params)
|
||||
const whereClause = conditions.join(" AND ")
|
||||
|
||||
let leads = result.rows.map((r: any) => {
|
||||
// Total count (same filters, no pagination)
|
||||
const countResult = await query(
|
||||
`SELECT COUNT(*) AS total FROM leads l JOIN lead_stages ls ON ls.id = l.stage_id WHERE ${whereClause}`,
|
||||
params.slice(0, paramIdx - 1),
|
||||
)
|
||||
const total = parseInt(countResult.rows[0]?.total || "0", 10)
|
||||
|
||||
// Data query with pagination
|
||||
const dataSql = `SELECT l.id, l.company_name, l.contact_name, l.email, l.phone, l.score,
|
||||
l.assigned_to, l.created_at, l.updated_at, l.notes, l.source_id,
|
||||
ls.name AS stage_name,
|
||||
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 ${whereClause}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
||||
const dataParams = [...params, limit, offset]
|
||||
const result = await query(dataSql, dataParams)
|
||||
|
||||
const leads = result.rows.map((r: any) => {
|
||||
const s = stageToStatus(r.stage_name)
|
||||
return {
|
||||
id: r.id,
|
||||
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
if (status !== "all") {
|
||||
leads = leads.filter((l: any) => l.status === status)
|
||||
}
|
||||
|
||||
return NextResponse.json(leads)
|
||||
return NextResponse.json({ leads, total })
|
||||
} catch (error) {
|
||||
console.error("Leads API error:", error)
|
||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||
|
||||
@@ -8,12 +8,13 @@ export async function GET() {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const result = await query(
|
||||
`SELECT preferences->>'website_theme' AS website_theme FROM users WHERE id = $1`,
|
||||
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
||||
return NextResponse.json({ websiteTheme })
|
||||
const websiteTheme = result.rows[0]?.website_theme || "default"
|
||||
const colorTheme = result.rows[0]?.color_theme || null
|
||||
return NextResponse.json({ websiteTheme, colorTheme })
|
||||
} catch (error) {
|
||||
console.error("Website theme GET error:", error)
|
||||
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
||||
@@ -26,14 +27,31 @@ export async function PUT(request: NextRequest) {
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
|
||||
const body = await request.json()
|
||||
const theme = body.websiteTheme || "spidey"
|
||||
const update: Record<string, string> = {}
|
||||
if (body.websiteTheme !== undefined) {
|
||||
update.website_theme = body.websiteTheme || "default"
|
||||
}
|
||||
if (body.colorTheme !== undefined) {
|
||||
update.color_theme = body.colorTheme || "default"
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||
[user.id, JSON.stringify({ website_theme: theme })],
|
||||
if (Object.keys(update).length > 0) {
|
||||
await query(
|
||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||
[user.id, JSON.stringify(update)],
|
||||
)
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`SELECT preferences->>'website_theme' AS website_theme, preferences->>'color_theme' AS color_theme FROM users WHERE id = $1`,
|
||||
[user.id],
|
||||
)
|
||||
|
||||
return NextResponse.json({ success: true, websiteTheme: theme })
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
websiteTheme: result.rows[0]?.website_theme || "default",
|
||||
colorTheme: result.rows[0]?.color_theme || null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Website theme PUT error:", error)
|
||||
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||
|
||||
@@ -8,9 +8,6 @@ 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
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getSessionUser } from "@/lib/auth"
|
||||
import { query } from "@/lib/db"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionUser = await getSessionUser()
|
||||
if (!sessionUser) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
if (sessionUser.role !== "super_admin" && sessionUser.role !== "admin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||
}
|
||||
|
||||
const phone = request.nextUrl.searchParams.get("phone")
|
||||
if (!phone) {
|
||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||
|
||||
@@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
if (!joined) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{connectionTimeout ? (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
|
||||
</>
|
||||
) : !isReady ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-4 py-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
|
||||
<p className="text-[#8A9078] text-sm">Connecting to call...</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
|
||||
{micError && (
|
||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
||||
<p className="text-[#C84B4B] text-xs mb-4">{micError}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
@@ -78,12 +78,12 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||
placeholder="Enter your name"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleJoin}
|
||||
disabled={!displayName.trim()}
|
||||
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
className="w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 disabled:bg-[#8A9078] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Join Call
|
||||
@@ -96,14 +96,14 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
{callState === "calling" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
|
||||
<p className="text-[#8A9078] text-sm animate-pulse">Connecting to call room...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
{callState === "waiting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
|
||||
<p className="text-[#8A9078] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||
{participants.length > 0 && (
|
||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<div className="mt-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
{callState === "participant_joined" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
|
||||
<p className="text-[#8A9078] text-sm animate-pulse">Connecting...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
{callState === "connecting" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
|
||||
<p className="text-[#8A9078] text-sm animate-pulse">Establishing secure connection...</p>
|
||||
<div className="flex justify-center mt-6">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
{callState === "connected" && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
|
||||
{participants.length > 0 && (
|
||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
||||
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||
<Users className="h-3 w-3" />
|
||||
Participants ({participants.length})
|
||||
</div>
|
||||
{participants.map((p) => (
|
||||
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
|
||||
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
||||
<div className="text-[#C84B4B] font-mono text-lg mb-6">
|
||||
{formatDuration(callDuration)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-4">
|
||||
<button
|
||||
onClick={toggleSpeaker}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMute}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
|
||||
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
|
||||
>
|
||||
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEnd}
|
||||
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
|
||||
>
|
||||
<PhoneOff className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
||||
|
||||
{(callState === "ended" || callState === "idle") && (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
|
||||
{callDuration > 0 && (
|
||||
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
<p className="text-[#8A9078] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
||||
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+73
-54
@@ -2,6 +2,8 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
|
||||
|
||||
@import "../styles/ai-assistant.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -76,72 +78,88 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: 220 14% 84%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 220 10% 91%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 220 14% 96%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 120 100% 50%;
|
||||
--background: 60 20% 95%;
|
||||
--foreground: 71 20% 16%;
|
||||
--card: 60 29% 97%;
|
||||
--card-foreground: 71 20% 16%;
|
||||
--popover: 60 29% 97%;
|
||||
--popover-foreground: 71 20% 16%;
|
||||
--primary: 0 53% 54%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 14% 88%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 220 14% 88%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 220 14% 88%;
|
||||
--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: 216 12% 92%;
|
||||
--sidebar-foreground: 0 0% 20%;
|
||||
--sidebar-primary: 0 91.2% 59.8%;
|
||||
--secondary: 210 47% 56%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--muted: 75 10% 93%;
|
||||
--muted-foreground: 75 10% 52%;
|
||||
--accent: 159 32% 43%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 0 53% 54%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 80 13% 82%;
|
||||
--input: 80 13% 82%;
|
||||
--ring: 0 53% 54%;
|
||||
--sidebar: 80 17% 92%;
|
||||
--sidebar-foreground: 71 20% 16%;
|
||||
--sidebar-primary: 0 53% 54%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 218 16% 87%;
|
||||
--sidebar-accent-foreground: 0 0% 20%;
|
||||
--sidebar-border: 220 11% 84%;
|
||||
--sidebar-ring: 0 76.3% 48%;
|
||||
--sidebar-accent: 80 17% 92%;
|
||||
--sidebar-accent-foreground: 71 20% 16%;
|
||||
--sidebar-border: 80 13% 82%;
|
||||
--sidebar-ring: 0 53% 54%;
|
||||
--radius: 0.5rem;
|
||||
--theme-primary: hsl(var(--primary));
|
||||
--event-meeting: 217 91% 55%;
|
||||
--color-blue: 210 47% 56%;
|
||||
--color-teal: 159 32% 43%;
|
||||
--color-red-tint: 0 62% 95%;
|
||||
--color-blue-tint: 208 54% 93%;
|
||||
--color-teal-tint: 157 35% 91%;
|
||||
--color-avatar: 102 21% 52%;
|
||||
--event-call: 160 84% 39%;
|
||||
--event-chat: 271 81% 53%;
|
||||
--event-follow_up: 38 92% 48%;
|
||||
--event-demo: 346 77% 48%;
|
||||
--event-other: 215 20% 55%;
|
||||
--event-website_creation: 263 70% 50%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 10%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 11%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 0 100% 53%;
|
||||
/* ── Slate palette tokens ── */
|
||||
--bg-page: 225 18% 9%;
|
||||
--bg-sidebar: 227 25% 7%;
|
||||
--bg-card: 224 24% 11%;
|
||||
--bg-card-inset: 224 24% 13%;
|
||||
--bg-nav-active: 221 43% 20%;
|
||||
--border: 228 21% 21%;
|
||||
--border-strong: 220 69% 59%;
|
||||
--text-primary: 226 30% 95%;
|
||||
--text-secondary: 228 13% 59%;
|
||||
--text-active: 221 100% 87%;
|
||||
--accent-hover: 220 70% 66%;
|
||||
|
||||
/* ── Existing CSS variable overrides ── */
|
||||
--background: 225 18% 9%;
|
||||
--foreground: 226 30% 95%;
|
||||
--card: 224 24% 11%;
|
||||
--card-foreground: 226 30% 95%;
|
||||
--popover: 224 24% 13%;
|
||||
--popover-foreground: 226 30% 95%;
|
||||
--primary: 220 69% 59%;
|
||||
--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%;
|
||||
--secondary: 224 24% 13%;
|
||||
--secondary-foreground: 226 30% 95%;
|
||||
--muted: 224 24% 13%;
|
||||
--muted-foreground: 228 13% 59%;
|
||||
--accent: 224 24% 13%;
|
||||
--accent-foreground: 226 30% 95%;
|
||||
--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: 0 0% 11%;
|
||||
--sidebar-foreground: 210 40% 98%;
|
||||
--sidebar-primary: 145 65% 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: 0 100% 53%;
|
||||
|
||||
--border: 228 21% 21%;
|
||||
--input: 228 21% 21%;
|
||||
--ring: 220 69% 59%;
|
||||
--sidebar: 227 25% 7%;
|
||||
--sidebar-foreground: 226 30% 95%;
|
||||
--sidebar-primary: 221 43% 20%;
|
||||
--sidebar-primary-foreground: 221 100% 87%;
|
||||
--sidebar-accent: 224 24% 13%;
|
||||
--sidebar-accent-foreground: 226 30% 95%;
|
||||
--sidebar-border: 228 21% 21%;
|
||||
--sidebar-ring: 220 69% 59%;
|
||||
}
|
||||
|
||||
.ocean {
|
||||
@@ -731,3 +749,4 @@ main {
|
||||
.checkbox-text { color: rgba(232,232,239,0.38); }
|
||||
.footer-text { color: rgba(232,232,239,0.2); }
|
||||
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ interface Props {
|
||||
|
||||
function ErrorPage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
|
||||
{message}
|
||||
</h1>
|
||||
</div>
|
||||
@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
|
||||
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
|
||||
You're Invited!
|
||||
</h1>
|
||||
<p className="text-[#888888] text-sm mb-6">
|
||||
<p className="text-[#8A9078] text-sm mb-6">
|
||||
Someone wants to connect with you on our platform.
|
||||
</p>
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
|
||||
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
|
||||
<p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
|
||||
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] mb-6">
|
||||
<p className="text-xs text-[#8A9078] mb-6">
|
||||
Create an account to start making free voice calls to this person and others on the platform.
|
||||
</p>
|
||||
<a
|
||||
href="/register"
|
||||
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
|
||||
>
|
||||
Create Account
|
||||
</a>
|
||||
|
||||
+7
-1
@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/providers/theme-provider"
|
||||
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import "./globals.css"
|
||||
import "../../Web_Backgrounds/default-theme.css"
|
||||
import "../../Web_Backgrounds/spidey-theme.css"
|
||||
|
||||
const inter = Inter({
|
||||
@@ -32,7 +33,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${inter.variable} min-h-screen antialiased`}>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `(function(){try{var t=localStorage.getItem("crm-color-theme");if(t&&t!=="default")document.documentElement.classList.add(t);var w=localStorage.getItem("crm-website-theme");if(w==="spidey")document.documentElement.classList.add("theme-spidey")}catch(e){}})()`,
|
||||
}}
|
||||
/>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
|
||||
<WebsiteThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useState, useEffect, useRef, Suspense } from "react"
|
||||
import { useSearchParams, useRouter } from "next/navigation"
|
||||
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
||||
|
||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||
|
||||
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)" },
|
||||
@@ -227,6 +229,9 @@ function LoginForm() {
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.removeItem(WEBSITE_THEME_KEY)
|
||||
const root = document.documentElement
|
||||
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
|
||||
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
||||
router.push(redirectTo)
|
||||
} else {
|
||||
|
||||
+277
-153
@@ -1,33 +1,136 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, Fragment } from "react"
|
||||
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
|
||||
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
||||
import { Bot, Terminal } from "lucide-react"
|
||||
import { GifPicker } from "./gif-picker"
|
||||
|
||||
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 <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-primary hover:text-primary/80">{part}</a>
|
||||
}
|
||||
return <Fragment key={i}>{part}</Fragment>
|
||||
})
|
||||
}
|
||||
|
||||
function formatContent(text: string) {
|
||||
const lines = text.split("\n")
|
||||
return lines.map((line, i) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith("•") || trimmed.startsWith("-")) {
|
||||
const content = trimmed.replace(/^[•\-]\s*/, "")
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2.5 my-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-primary rounded-sm inline-block mt-2 flex-shrink-0" />
|
||||
<span>{linkifyText(content)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (line === "") return <div key={i} className="h-2" />
|
||||
return <p key={i} className="my-1">{linkifyText(line)}</p>
|
||||
})
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string
|
||||
ts?: number
|
||||
gif?: {
|
||||
url: string
|
||||
previewUrl: string
|
||||
title: string
|
||||
}
|
||||
}
|
||||
|
||||
export function AIChat() {
|
||||
interface AIChatProps {
|
||||
onMessageSent?: (msg: string) => void
|
||||
}
|
||||
|
||||
|
||||
export interface AIChatHandle {
|
||||
fillInput: (text: string) => void
|
||||
addAssistantMessage: (content: string) => void
|
||||
}
|
||||
|
||||
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [input, setInput] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||
const [showGifPicker, setShowGifPicker] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const hasUserMessage = messages.some(m => m.role === "user")
|
||||
const loadedFromStorage = useRef(false)
|
||||
|
||||
const checkServer = async () => {
|
||||
useImperativeHandle(ref, () => ({
|
||||
fillInput(text: string) {
|
||||
setInput(text)
|
||||
setTimeout(() => textareaRef.current?.focus(), 50)
|
||||
},
|
||||
addAssistantMessage(content: string) {
|
||||
setMessages((prev) => {
|
||||
if (!prev.some((m) => m.role === "user")) {
|
||||
return [
|
||||
{ role: "user", content: "Search Facebook" },
|
||||
{ role: "assistant", content },
|
||||
]
|
||||
}
|
||||
return [...prev, { role: "assistant", content }]
|
||||
})
|
||||
},
|
||||
}), [])
|
||||
|
||||
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
|
||||
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
|
||||
setShowGifPicker(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (showGifPicker) {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowGifPicker(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handler)
|
||||
return () => document.removeEventListener("mousedown", handler)
|
||||
}
|
||||
}, [showGifPicker])
|
||||
|
||||
const _WEEK_MS = 604800000
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("ai-chat-messages")
|
||||
if (saved) {
|
||||
try {
|
||||
let parsed = JSON.parse(saved)
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
const cutoff = Date.now() - _WEEK_MS
|
||||
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
|
||||
if (parsed.length > 0) {
|
||||
setMessages(parsed)
|
||||
loadedFromStorage.current = true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} else {
|
||||
loadedFromStorage.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
const now = Date.now()
|
||||
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const checkServer = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/ai/chat", {
|
||||
method: "POST",
|
||||
@@ -42,9 +145,11 @@ export function AIChat() {
|
||||
} catch {
|
||||
setTimeout(checkServer, 2000)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkServer()
|
||||
if (loadedFromStorage.current) return
|
||||
fetch("/api/ai/jobs")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
@@ -73,19 +178,19 @@ export function AIChat() {
|
||||
},
|
||||
])
|
||||
})
|
||||
checkServer()
|
||||
}, [])
|
||||
}, [checkServer])
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [messages])
|
||||
|
||||
const sendMessage = async () => {
|
||||
const msg = input.trim()
|
||||
const sendMessage = useCallback(async (text?: string) => {
|
||||
const msg = (text || input).trim()
|
||||
if (!msg || loading) return
|
||||
|
||||
setInput("")
|
||||
setError("")
|
||||
onMessageSent?.(msg)
|
||||
setMessages((prev) => [...prev, { role: "user", content: msg }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -113,167 +218,186 @@ export function AIChat() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [input, loading, onMessageSent])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}, [sendMessage])
|
||||
|
||||
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const t = e.target as HTMLTextAreaElement
|
||||
t.style.height = "auto"
|
||||
t.style.height = t.scrollHeight + "px"
|
||||
}, [])
|
||||
|
||||
if (bootState === "booting") {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center gap-5">
|
||||
<p className="text-sm text-muted-foreground animate-pulse">Servers booting...</p>
|
||||
<div className="h-[56px] w-[110px] bg-card border border-border rounded-xl flex items-center justify-center overflow-hidden">
|
||||
<div className="robot-walk relative">
|
||||
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||
<rect x="10" y="2" width="20" height="16" rx="3" fill="hsl(var(--primary))" opacity="0.9"/>
|
||||
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="hsl(var(--primary))" opacity="0.7"/>
|
||||
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="hsl(var(--primary))" opacity="0.7"/>
|
||||
<rect x="14" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||
<rect x="22" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
|
||||
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
|
||||
<rect x="17" y="10" width="6" height="2" rx="1" style={{fill: "hsl(var(--background))"}}/>
|
||||
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="hsl(var(--primary))" opacity="0.8"/>
|
||||
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="hsl(var(--primary))" opacity="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const bootOverlay = (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<style>{`
|
||||
@keyframes walk {
|
||||
0%, 100% { transform: translateX(0) translateY(0); }
|
||||
25% { transform: translateX(12px) translateY(-4px); }
|
||||
50% { transform: translateX(24px) translateY(0); }
|
||||
75% { transform: translateX(12px) translateY(-4px); }
|
||||
}
|
||||
@keyframes legLeft {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
}
|
||||
@keyframes legRight {
|
||||
0%, 100% { transform: rotate(10deg); }
|
||||
50% { transform: rotate(-10deg); }
|
||||
}
|
||||
@keyframes armLeft {
|
||||
0%, 100% { transform: rotate(15deg); }
|
||||
50% { transform: rotate(-15deg); }
|
||||
}
|
||||
@keyframes armRight {
|
||||
0%, 100% { transform: rotate(-15deg); }
|
||||
50% { transform: rotate(15deg); }
|
||||
}
|
||||
@keyframes blink {
|
||||
0%, 45%, 55%, 100% { height: 4px; }
|
||||
50% { height: 1px; }
|
||||
}
|
||||
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||
`}</style>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
|
||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
|
||||
<div className="robot-walk relative">
|
||||
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
||||
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
|
||||
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
|
||||
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
|
||||
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
||||
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
||||
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
|
||||
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
|
||||
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
|
||||
</svg>
|
||||
if (bootState === "ready" && !hasUserMessage) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="max-w-lg mx-auto px-4">
|
||||
<div className="float-in">
|
||||
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-primary to-primary flex items-center justify-center shadow-[0_0_40px_hsl(var(--primary)_/_0.3)]">
|
||||
<Bot className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h2 className="text-foreground font-bold text-2xl text-center mb-2">What can I help you with?</h2>
|
||||
<p className="text-muted-foreground text-sm text-center leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
|
||||
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onInput={handleTextareaInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask for sales tips..."
|
||||
rows={1}
|
||||
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage()}
|
||||
disabled={!input.trim()}
|
||||
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 px-1">
|
||||
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
|
||||
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const readyOverlay = (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
|
||||
<Check className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
|
||||
<div className="flex gap-2">
|
||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
|
||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (bootState === "booting") return bootOverlay
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{bootState === "ready" && readyOverlay}
|
||||
|
||||
<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 className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i}>
|
||||
{msg.role === "assistant" ? (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 max-w-[85%]">
|
||||
<div className="text-foreground text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-[10px] mt-1">AI Assistant</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-3 items-start flex-row-reverse">
|
||||
<div className="flex-1 min-w-0 flex justify-end">
|
||||
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
||||
{msg.gif ? (
|
||||
<img
|
||||
src={msg.gif.url}
|
||||
alt={msg.gif.title || "GIF"}
|
||||
className="w-full rounded-lg max-h-64 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex gap-3 items-start">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||
<Bot className="h-4 w-4" />
|
||||
</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 className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 inline-flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-primary dot-1" />
|
||||
<span className="w-2 h-2 rounded-full bg-primary dot-2" />
|
||||
<span className="w-2 h-2 rounded-full bg-primary dot-3" />
|
||||
</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]">
|
||||
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</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 className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
{error && (
|
||||
<div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
|
||||
<span>⚠️</span>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
|
||||
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onInput={handleTextareaInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask for sales tips..."
|
||||
rows={1}
|
||||
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage()}
|
||||
disabled={!input.trim()}
|
||||
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
|
||||
>
|
||||
➤
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-2 px-1">
|
||||
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
|
||||
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
|
||||
</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 ? (
|
||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
||||
</svg>
|
||||
) : <Send className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
AIChat.displayName = "AIChat"
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { Search, Loader2, ImageIcon } from "lucide-react"
|
||||
|
||||
interface GifResult {
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
previewUrl: string
|
||||
previewHeight: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface GifPickerProps {
|
||||
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const SEARCH_CACHE = new Map<string, GifResult[]>()
|
||||
|
||||
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
|
||||
const [gifs, setGifs] = useState<GifResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [search, setSearch] = useState("")
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [error, setError] = useState("")
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
|
||||
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
|
||||
if (off === 0) {
|
||||
const cacheKey = q || "__trending__"
|
||||
const cached = SEARCH_CACHE.get(cacheKey)
|
||||
if (cached) {
|
||||
setGifs(cached)
|
||||
setLoading(false)
|
||||
setHasMore(true)
|
||||
setOffset(cached.length)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!append) setLoading(true)
|
||||
else setLoadingMore(true)
|
||||
setError("")
|
||||
|
||||
const params = new URLSearchParams()
|
||||
if (q) {
|
||||
params.set("type", "search")
|
||||
params.set("q", q)
|
||||
}
|
||||
params.set("offset", String(off))
|
||||
params.set("limit", "20")
|
||||
|
||||
const res = await fetch(`/api/ai/giphy?${params}`)
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
throw new Error(data.error || "Failed to fetch GIFs")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
const newGifs: GifResult[] = data.gifs || []
|
||||
|
||||
if (append) {
|
||||
setGifs((prev) => [...prev, ...newGifs])
|
||||
} else {
|
||||
setGifs(newGifs)
|
||||
if (off === 0 && !q) {
|
||||
SEARCH_CACHE.set("__trending__", newGifs)
|
||||
}
|
||||
if (q) {
|
||||
SEARCH_CACHE.set(q, newGifs)
|
||||
}
|
||||
}
|
||||
|
||||
setOffset(off + newGifs.length)
|
||||
setHasMore(newGifs.length === 20)
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load GIFs")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchGifs("", 0, false)
|
||||
}, [fetchGifs])
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||
if (!search.trim()) {
|
||||
fetchGifs("", 0, false)
|
||||
return
|
||||
}
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
fetchGifs(search.trim(), 0, false)
|
||||
}, 400)
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}, [search, fetchGifs])
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
|
||||
fetchGifs(search.trim(), offset, true)
|
||||
}
|
||||
},
|
||||
{ rootMargin: "200px" }
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search GIFs..."
|
||||
className="w-full bg-muted/50 text-foreground text-sm rounded-xl pl-9 pr-4 py-2.5 outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">{error}</p>
|
||||
</div>
|
||||
) : gifs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No GIFs found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
{gifs.map((gif) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
type="button"
|
||||
onClick={() => onSelect({ url: gif.url, previewUrl: gif.previewUrl, title: gif.title })}
|
||||
className="relative rounded-xl overflow-hidden bg-muted/30 hover:ring-2 hover:ring-primary/50 transition-all duration-200 aspect-video"
|
||||
title={gif.title}
|
||||
>
|
||||
<img
|
||||
src={gif.previewUrl}
|
||||
alt={gif.title || "GIF"}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{loadingMore && (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
{hasMore && <div ref={sentinelRef} className="h-4" />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Briefcase, ChevronDown, Loader2 } from "lucide-react"
|
||||
import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
|
||||
|
||||
interface Job {
|
||||
job_title: string
|
||||
@@ -12,9 +12,11 @@ interface Job {
|
||||
|
||||
interface JobSelectorProps {
|
||||
onSelect: (job: Job | null) => void
|
||||
onSearch?: (job: Job) => void
|
||||
searching?: boolean
|
||||
}
|
||||
|
||||
export function JobSelector({ onSelect }: JobSelectorProps) {
|
||||
export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -39,36 +41,47 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
|
||||
<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"
|
||||
className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
|
||||
>
|
||||
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
|
||||
<Briefcase className="h-4 w-4 text-[#f97316] 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]" />}
|
||||
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
|
||||
</button>
|
||||
|
||||
{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">
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
|
||||
{jobs.map((job, i) => (
|
||||
<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"
|
||||
className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-b-0 border-l-2 border-l-transparent hover:border-l-primary/40"
|
||||
>
|
||||
<div className="font-medium">{job.job_title}</div>
|
||||
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} — {job.description}</div>
|
||||
<div className="text-xs text-muted-foreground/60 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 className="px-4 py-4 text-xs text-muted-foreground/60 text-center">No job categories loaded</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selected && onSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearch(selected)}
|
||||
disabled={searching}
|
||||
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
|
||||
>
|
||||
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||
{searching ? "Searching..." : "Search Facebook"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
|
||||
import data from "@emoji-mart/data"
|
||||
import Picker from "@emoji-mart/react"
|
||||
import { getStickerPacks, type StickerPack } from "@/data/stickers"
|
||||
import { sanitizeSvg } from "@/lib/sanitize"
|
||||
|
||||
type Tab = "emoji" | "gif" | "sticker"
|
||||
|
||||
@@ -57,7 +58,13 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
return () => document.removeEventListener("mousedown", handleClick)
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, [])
|
||||
useEffect(() => {
|
||||
setRecentGifs(getRecent(RECENT_GIFS_KEY))
|
||||
if (!navigator.onLine) {
|
||||
console.warn("[GIF Picker] Browser is offline — GIF service may be unavailable")
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
|
||||
|
||||
const fetchGifs = useCallback(async (query: string, pos = "") => {
|
||||
@@ -76,6 +83,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
const res = await fetch(`/api/gifs?${params}`)
|
||||
const data = await res.json()
|
||||
if (data.noKey) {
|
||||
console.error("[GIF Picker] GIPHY_API_KEY is not configured on the server. Add GIPHY_API_KEY to .env.local and restart the dev server.")
|
||||
setGifUnavailable(true)
|
||||
setGifResults([])
|
||||
} else if (data.results) {
|
||||
@@ -103,6 +111,15 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
|
||||
}, [tab, gifQuery, fetchGifs])
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== "gif" || !gifError) return
|
||||
const interval = setInterval(() => {
|
||||
if (gifUnavailable) return
|
||||
fetchGifs(gifQuery.trim())
|
||||
}, 15000)
|
||||
return () => clearInterval(interval)
|
||||
}, [tab, gifError, gifUnavailable, gifQuery, fetchGifs])
|
||||
|
||||
const handleGifSelect = (gif: any) => {
|
||||
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
|
||||
addRecent(RECENT_GIFS_KEY, gif.id)
|
||||
@@ -208,7 +225,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
) : gifUnavailable ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||
<Image className="h-8 w-8 opacity-40" />
|
||||
<span>GIFs are temporarily unavailable.</span>
|
||||
<span>GIF service not configured.</span>
|
||||
</div>
|
||||
) : gifError ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
|
||||
@@ -271,7 +288,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
|
||||
>
|
||||
{sticker.svg ? (
|
||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
|
||||
) : (
|
||||
<span className="text-5xl">{sticker.emoji}</span>
|
||||
)}
|
||||
@@ -287,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
|
||||
if (!s) return null
|
||||
return (
|
||||
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
|
||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -162,11 +162,11 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
className="absolute top-4 right-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -174,27 +174,27 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
{callLink ? (
|
||||
shareSent ? (
|
||||
<>
|
||||
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
<p className="text-sm text-[#4A9078] font-semibold text-center mb-3">Call link copied.</p>
|
||||
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Waiting for participant</h2>
|
||||
<p className="text-[#8A9078] text-sm mt-1 mb-4">
|
||||
The recipient must receive and open the call link.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
<div className="bg-card dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-foreground dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShareSent(false); setShareError(null) }}
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
className="flex-1 flex items-center justify-center gap-2 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
WhatsApp
|
||||
@@ -205,24 +205,24 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
href={callLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||
className="block w-full mt-4 bg-muted-foreground hover:bg-muted-foreground/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
|
||||
>
|
||||
Open Call
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-4">
|
||||
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Choose how to notify this person</h2>
|
||||
<p className="text-[#8A9078] text-sm mt-1 mb-4">
|
||||
Select a method to send the call link
|
||||
</p>
|
||||
|
||||
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
|
||||
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#2D3020] dark:text-white">
|
||||
{callLink}
|
||||
</div>
|
||||
|
||||
{showWaUrl && (
|
||||
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
|
||||
<div className="bg-card dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#4A9078] font-mono">
|
||||
{showWaUrl}
|
||||
</div>
|
||||
)}
|
||||
@@ -237,7 +237,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={copyLink}
|
||||
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
className="flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy URL
|
||||
@@ -245,16 +245,16 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
</div>
|
||||
|
||||
{shareError && (
|
||||
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
|
||||
<p className="text-[#C84B4B] text-xs text-center">{shareError}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
|
||||
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Start a Call</h2>
|
||||
<p className="text-[#8A9078] text-sm mt-1 mb-5">Search contacts or dial a number</p>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
|
||||
CONTACTS
|
||||
</p>
|
||||
|
||||
@@ -262,25 +262,25 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search contacts..."
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
|
||||
{contactsLoading && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||
<p className="text-[#8A9078] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
|
||||
)}
|
||||
{contactsError && (
|
||||
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
|
||||
<p className="text-[#C84B4B] text-sm text-center py-4">Could not load contacts</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
|
||||
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
|
||||
<p className="text-[#8A9078] text-sm text-center py-4">No contacts found</p>
|
||||
)}
|
||||
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
|
||||
<div
|
||||
key={contact.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#FAFAF6] dark:hover:bg-[#1A1A1A]"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
|
||||
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FAEAEA] dark:bg-[#CC0000]/15 text-[#C84B4B] dark:text-[#FF4444]">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
@@ -292,13 +292,13 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
|
||||
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
|
||||
<p className="text-sm font-medium text-[#2D3020] dark:text-white truncate">{contact.name}</p>
|
||||
<p className="text-xs text-[#8A9078] dark:[#666666] truncate">{contact.phone}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCall(contact.phone)}
|
||||
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||
className="shrink-0 text-[#C84B4B] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -307,12 +307,12 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 my-4">
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
|
||||
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
|
||||
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
|
||||
<span className="text-xs text-[#8A9078] dark:text-[#555555]">OR</span>
|
||||
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
|
||||
DIAL A NUMBER
|
||||
</p>
|
||||
|
||||
@@ -321,10 +321,10 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
value={phoneNumber}
|
||||
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
|
||||
placeholder="+27 000 000 0000"
|
||||
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
|
||||
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
|
||||
/>
|
||||
{phoneError && (
|
||||
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
|
||||
<p className="text-[#C84B4B] text-xs mt-1">Please enter a phone number</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
@@ -337,7 +337,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
|
||||
setPhoneError(false)
|
||||
handleCall(trimmed)
|
||||
}}
|
||||
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
className="w-full mt-3 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
|
||||
>
|
||||
<Phone className="h-4 w-4" />
|
||||
Call Now
|
||||
|
||||
@@ -21,14 +21,14 @@ export function CrossedLightsabers() {
|
||||
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
|
||||
|
||||
<g className="animate-pulse-red">
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-outer" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-mid" />
|
||||
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
|
||||
</g>
|
||||
|
||||
<g className="animate-pulse-blue">
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-outer" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-mid" />
|
||||
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
|
||||
</g>
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
|
||||
data: IntervalData[]
|
||||
}
|
||||
|
||||
const NEW_LEADS = "#CC0000"
|
||||
const CLOSED = "#0033CC"
|
||||
const NEW_LEADS = "#C84B4B"
|
||||
const CLOSED = "#5A8FC4"
|
||||
|
||||
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#FF1111" />
|
||||
<stop offset="0%" stopColor="#C84B4B" />
|
||||
<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="0%" stopColor="#5A8FC4" />
|
||||
<stop offset="100%" stopColor={CLOSED} />
|
||||
</linearGradient>
|
||||
<filter id="shadowNew">
|
||||
|
||||
@@ -23,10 +23,10 @@ export function StatCardSkeleton() {
|
||||
|
||||
return (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-20">
|
||||
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
|
||||
<p className="text-[#8A9078] dark:text-[#AAAAAA] text-sm">
|
||||
Loading your data...
|
||||
</p>
|
||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
|
||||
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#D4D8CC] dark:bg-[#222222] mt-4">
|
||||
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { LucideIcon } from "lucide-react"
|
||||
|
||||
@@ -19,12 +20,12 @@ interface StatCardProps {
|
||||
}
|
||||
|
||||
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" },
|
||||
"Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||
"Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||
"Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||
"Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
|
||||
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
|
||||
}
|
||||
|
||||
function computeGoal(max: number): number {
|
||||
@@ -50,6 +51,7 @@ function smoothPath(points: { x: number; y: number }[]): string {
|
||||
}
|
||||
|
||||
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
|
||||
const { websiteTheme } = useWebsiteTheme()
|
||||
const color = cardColors[title] ?? cardColors["Total Leads"]
|
||||
|
||||
const isNumeric = typeof value === "number"
|
||||
@@ -108,8 +110,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
||||
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"
|
||||
const stripColor = isRed ? "from-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
|
||||
const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -119,28 +121,30 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
||||
className="relative"
|
||||
>
|
||||
<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`} />
|
||||
{websiteTheme === "spidey" && (
|
||||
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
|
||||
)}
|
||||
|
||||
{/* Action lines on hover */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
/>
|
||||
{websiteTheme === "spidey" && (
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CardContent className="p-6 flex flex-col relative z-[1]">
|
||||
<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">
|
||||
<p className="text-3xl font-bold tracking-tight text-[#2D3020] dark:text-white">
|
||||
{isNumeric ? display : value}
|
||||
</p>
|
||||
{description && (
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
|
||||
<p className="text-xs text-[#8A9078] dark:text-[#666666]">{description}</p>
|
||||
)}
|
||||
{trend && (
|
||||
<span className={cn(
|
||||
@@ -196,9 +200,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
|
||||
{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 className="h-full rounded-full bg-gradient-to-r from-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||
<p className="text-xs text-[#8A9078] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
import { motion, AnimatePresence } from "framer-motion"
|
||||
import { Sidebar } from "./sidebar"
|
||||
import { Topbar } from "./topbar"
|
||||
@@ -11,6 +12,7 @@ interface AppShellProps {
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const { websiteTheme } = useWebsiteTheme()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [mobileOpen, setMobileOpen] = useState(false)
|
||||
const pathname = usePathname()
|
||||
@@ -33,51 +35,55 @@ export function AppShell({ children }: AppShellProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||
style={{
|
||||
<div className="min-h-screen bg-background dark:bg-[#0A0A0A] relative overflow-hidden"
|
||||
style={websiteTheme === "spidey" ? {
|
||||
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px, 14px 14px",
|
||||
backgroundPosition: "0 0, 7px 7px",
|
||||
}}
|
||||
} : undefined}
|
||||
>
|
||||
{/* Spider-Man top gradient bar */}
|
||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||
{websiteTheme === "spidey" && (
|
||||
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
|
||||
)}
|
||||
|
||||
{/* Corner spider webs */}
|
||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
{websiteTheme === "spidey" && (
|
||||
<>
|
||||
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
|
||||
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
|
||||
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
|
||||
@@ -8,17 +8,17 @@ export function CrmIcon() {
|
||||
viewBox="0 0 64 64"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
|
||||
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
|
||||
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
|
||||
</g>
|
||||
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
|
||||
<g className="fill-[#8A9078] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
|
||||
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
|
||||
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
|
||||
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
|
||||
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
|
||||
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
|
||||
</g>
|
||||
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
|
||||
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
|
||||
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
|
||||
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
|
||||
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
|
||||
|
||||
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 192.756 192.756"
|
||||
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
|
||||
className="fill-[#2D3020] dark:fill-[#eef0f7] w-full h-auto"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g fill-rule="evenodd" clip-rule="evenodd">
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
|
||||
<Link href="/" className="flex items-center gap-3 overflow-hidden">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 overflow-hidden">
|
||||
{collapsed ? (
|
||||
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
|
||||
) : (
|
||||
|
||||
@@ -73,7 +73,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
|
||||
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-2 mr-2 shrink-0">
|
||||
{websiteTheme === "spidey" && <CaptainAmericaShield />}
|
||||
@@ -94,10 +94,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative hidden flex-1 sm:block md:w-80">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search leads, companies..."
|
||||
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
|
||||
className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +117,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
className="text-muted-foreground"
|
||||
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||
>
|
||||
{mounted ? (
|
||||
theme === "dark" ? (
|
||||
@@ -135,7 +135,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setBugModalOpen(true)}
|
||||
className="text-muted-foreground"
|
||||
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||
title="Report a Bug"
|
||||
>
|
||||
<Bug className="h-5 w-5" />
|
||||
@@ -147,7 +147,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative text-muted-foreground"
|
||||
className="relative text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
|
||||
@@ -217,7 +217,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
|
||||
{/* User profile */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
|
||||
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3 hover:bg-[#8b949e]/10 hover:text-[#8b949e]">
|
||||
<Avatar className="h-7 w-7">
|
||||
<AvatarImage src={user.avatar} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
|
||||
@@ -6,11 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Sun, Moon, Monitor, Shield } from "lucide-react"
|
||||
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
|
||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||
|
||||
const COLOR_THEME_KEY = "crm-color-theme"
|
||||
|
||||
const modeOptions = [
|
||||
{ value: "light", icon: Sun, label: "Light" },
|
||||
{ value: "dark", icon: Moon, label: "Dark" },
|
||||
@@ -31,41 +29,19 @@ const themeOptions = [
|
||||
]
|
||||
|
||||
const backgroundOptions = [
|
||||
{ value: "default", label: "Default", icon: Shield, color: "bg-zinc-500", ring: "ring-zinc-500", desc: "Standard CRM appearance" },
|
||||
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
|
||||
]
|
||||
|
||||
function getStoredColorTheme(): string {
|
||||
if (typeof window === "undefined") return "default"
|
||||
return localStorage.getItem(COLOR_THEME_KEY) || "default"
|
||||
}
|
||||
|
||||
function applyColorTheme(theme: string) {
|
||||
document.documentElement.className = document.documentElement.className
|
||||
.replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "")
|
||||
.trim()
|
||||
if (theme !== "default") {
|
||||
document.documentElement.classList.add(theme)
|
||||
}
|
||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||
}
|
||||
|
||||
export function ThemeSettings() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme()
|
||||
const [colorTheme, setColorTheme] = useState("default")
|
||||
const { websiteTheme, setWebsiteTheme, colorTheme, setColorTheme } = useWebsiteTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
setColorTheme(getStoredColorTheme())
|
||||
applyColorTheme(getStoredColorTheme())
|
||||
}, [])
|
||||
|
||||
function handleColorChange(value: string) {
|
||||
setColorTheme(value)
|
||||
applyColorTheme(value)
|
||||
}
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
return (
|
||||
@@ -85,7 +61,8 @@ export function ThemeSettings() {
|
||||
<Label
|
||||
htmlFor={`mode-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 cursor-pointer transition-all",
|
||||
value === "dark" ? "hover:bg-muted" : "hover:bg-accent",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
@@ -106,14 +83,14 @@ export function ThemeSettings() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4">
|
||||
<RadioGroup value={colorTheme} onValueChange={setColorTheme} className="grid grid-cols-5 gap-4">
|
||||
{themeOptions.map(({ value, label, color, ring }) => (
|
||||
<div key={value}>
|
||||
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
|
||||
<Label
|
||||
htmlFor={`color-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
@@ -141,7 +118,7 @@ export function ThemeSettings() {
|
||||
<Label
|
||||
htmlFor={`bg-${value}`}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all",
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
|
||||
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -75,7 +75,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
|
||||
className="absolute right-4 top-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@@ -83,15 +83,15 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
{submitted ? (
|
||||
<div className="flex flex-col items-center py-8 text-center">
|
||||
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
|
||||
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white mb-2">
|
||||
Bug Report Submitted
|
||||
</h3>
|
||||
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
|
||||
<p className="text-sm text-[#8A9078] dark:text-[#AAAAAA]">
|
||||
Thank you for your report. The development team will investigate.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
|
||||
className="mt-6 rounded-lg bg-[#C84B4B] px-6 py-2 text-sm font-medium text-white hover:bg-[#C84B4B]/80 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
@@ -99,14 +99,14 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<Bug className="h-5 w-5 text-[#CC0000]" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#FAEAEA] dark:bg-red-900/30">
|
||||
<Bug className="h-5 w-5 text-[#C84B4B]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
|
||||
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white">
|
||||
Report a Bug
|
||||
</h3>
|
||||
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
|
||||
<p className="text-xs text-[#8A9078] dark:text-[#AAAAAA]">
|
||||
Help us improve the system
|
||||
</p>
|
||||
</div>
|
||||
@@ -120,8 +120,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
Title <span className="text-[#CC0000]">*</span>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||
Title <span className="text-[#C84B4B]">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -129,13 +129,13 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Brief description of the issue"
|
||||
required
|
||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
Description <span className="text-[#CC0000]">*</span>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||
Description <span className="text-[#C84B4B]">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
@@ -143,18 +143,18 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
placeholder="What happened? What did you expect?"
|
||||
rows={4}
|
||||
required
|
||||
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
className="w-full resize-none rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
|
||||
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
|
||||
Severity
|
||||
</label>
|
||||
<select
|
||||
value={severity}
|
||||
onChange={(e) => setSeverity(e.target.value)}
|
||||
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
|
||||
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
|
||||
>
|
||||
<option value="low">Low — Minor cosmetic issue</option>
|
||||
<option value="medium">Medium — Affects functionality</option>
|
||||
@@ -163,8 +163,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
|
||||
<p className="text-xs text-[#888888]">
|
||||
<div className="rounded-lg bg-[#FAFAF6] dark:bg-[#1A1A1A] px-3 py-2">
|
||||
<p className="text-xs text-[#8A9078]">
|
||||
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -172,7 +172,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#C84B4B] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#C84B4B]/80 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{loading ? "Submitting..." : "Submit Bug Report"}
|
||||
|
||||
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
|
||||
className={cn("flex items-center justify-between pb-6", className)}
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
|
||||
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-primary pl-4 text-[#2D3020] dark:text-white">{title}</h1>
|
||||
{description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
|
||||
</div>
|
||||
{children && <div className="flex items-center gap-3">{children}</div>}
|
||||
</motion.div>
|
||||
|
||||
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
|
||||
})
|
||||
|
||||
return [
|
||||
{ name: "Open", value: statusCounts.open, color: "#CC0000" },
|
||||
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
|
||||
{ name: "Open", value: statusCounts.open, color: "#C84B4B" },
|
||||
{ name: "Contacted", value: statusCounts.contacted, color: "#5A8FC4" },
|
||||
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
|
||||
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" },
|
||||
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
|
||||
{ name: "Closed", value: statusCounts.closed, color: "#5A8FC4" },
|
||||
{ name: "Ignored", value: statusCounts.ignored, color: "#C84B4B" },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+2
-28
@@ -1,16 +1,7 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { query } from "@/lib/db";
|
||||
import { cookies } from "next/headers";
|
||||
import crypto from "crypto";
|
||||
|
||||
const RAW_SECRET = process.env.NODE_ENV === "production"
|
||||
? process.env.JWT_SECRET
|
||||
: crypto.randomUUID();
|
||||
if (!RAW_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required");
|
||||
}
|
||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
|
||||
import { signToken, verifyToken } from "@/lib/jwt";
|
||||
|
||||
export const SESSION_COOKIE = "session";
|
||||
const MAX_FAILED_ATTEMPTS = 5;
|
||||
@@ -38,23 +29,6 @@ export async function comparePassword(
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export async function signToken(payload: { userId: string; role: string }) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(JWT_SECRET);
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWT_SECRET);
|
||||
return payload as { userId: string; role: string };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const result = await query(
|
||||
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
|
||||
@@ -216,7 +190,7 @@ export async function decryptPassword(encrypted: string): Promise<string | null>
|
||||
}
|
||||
|
||||
export async function setSessionContext(userId: string, ip?: string) {
|
||||
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]);
|
||||
await query("SELECT set_session_user_context($1)", [userId]);
|
||||
if (ip) {
|
||||
await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
|
||||
}
|
||||
|
||||
+24
-1
@@ -1,4 +1,4 @@
|
||||
import { Pool } from "pg"
|
||||
import { Pool, PoolClient } from "pg"
|
||||
|
||||
const dbUrl = process.env.DATABASE_URL
|
||||
if (!dbUrl) {
|
||||
@@ -9,6 +9,8 @@ const pool = new Pool({
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
statement_timeout: 30000,
|
||||
idle_in_transaction_session_timeout: 10000,
|
||||
ssl: dbUrl.includes("localhost") || dbUrl.includes("127.0.0.1") ? false : { rejectUnauthorized: false },
|
||||
})
|
||||
|
||||
@@ -29,4 +31,25 @@ export async function query(text: string, params?: unknown[], userId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function transaction<T>(
|
||||
callback: (client: PoolClient) => Promise<T>,
|
||||
userId?: string,
|
||||
): Promise<T> {
|
||||
const client = await pool.connect()
|
||||
try {
|
||||
await client.query("BEGIN")
|
||||
if (userId) {
|
||||
await client.query("SELECT set_config('app.current_user_id', $1, true)", [userId])
|
||||
}
|
||||
const result = await callback(client)
|
||||
await client.query("COMMIT")
|
||||
return result
|
||||
} catch (e) {
|
||||
await client.query("ROLLBACK").catch(() => {})
|
||||
throw e
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
export default pool
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "resch
|
||||
<html><head><meta charset="utf-8"></head>
|
||||
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
|
||||
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
|
||||
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
|
||||
<div style="background:linear-gradient(135deg,#C84B4B,#5A8FC4);padding:24px 32px">
|
||||
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
|
||||
</div>
|
||||
<div style="padding:32px">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { jwtVerify, SignJWT } from "jose"
|
||||
|
||||
let encoded: Uint8Array
|
||||
|
||||
export function getJWTSecret(): Uint8Array {
|
||||
if (!encoded) {
|
||||
const raw = process.env.JWT_SECRET
|
||||
if (!raw) {
|
||||
throw new Error(
|
||||
"JWT_SECRET environment variable is required. " +
|
||||
"Generate one: openssl rand -hex 32",
|
||||
)
|
||||
}
|
||||
encoded = new TextEncoder().encode(raw)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
export async function verifyToken(token: string) {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, getJWTSecret())
|
||||
return payload as { userId: string; role: string }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function signToken(payload: { userId: string; role: string }) {
|
||||
return new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(getJWTSecret())
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
return svg
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
|
||||
.replace(/\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||
.replace(/javascript\s*:/gi, "")
|
||||
}
|
||||
+13
-13
@@ -1,12 +1,6 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import { jwtVerify } from "jose"
|
||||
|
||||
const RAW_SECRET = process.env.JWT_SECRET
|
||||
if (!RAW_SECRET) {
|
||||
throw new Error("JWT_SECRET environment variable is required")
|
||||
}
|
||||
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
|
||||
import { verifyToken } from "@/lib/jwt"
|
||||
|
||||
const publicRoutes = [
|
||||
"/login",
|
||||
@@ -19,6 +13,8 @@ const publicRoutes = [
|
||||
"/fonts",
|
||||
]
|
||||
|
||||
const SESSION_COOKIE = "session"
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
@@ -30,9 +26,9 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
const sessionCookie = request.cookies.get("session")?.value
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value
|
||||
|
||||
if (!sessionCookie) {
|
||||
if (!token) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
@@ -41,10 +37,8 @@ export async function middleware(request: NextRequest) {
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
await jwtVerify(sessionCookie, JWT_SECRET)
|
||||
return NextResponse.next()
|
||||
} catch {
|
||||
const payload = await verifyToken(token)
|
||||
if (!payload) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
@@ -52,6 +46,12 @@ export async function middleware(request: NextRequest) {
|
||||
loginUrl.searchParams.set("redirect", pathname)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers)
|
||||
requestHeaders.set("x-user-id", payload.userId)
|
||||
requestHeaders.set("x-user-role", payload.role)
|
||||
|
||||
return NextResponse.next({ request: { headers: requestHeaders } })
|
||||
}
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -3,14 +3,19 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from "react"
|
||||
|
||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||
const COLOR_THEME_KEY = "crm-color-theme"
|
||||
|
||||
const themeClasses: Record<string, string> = {
|
||||
spidey: "theme-spidey",
|
||||
}
|
||||
|
||||
const colorThemes = ["default","ocean","forest","sunset","midnight","rose","amber","violet","slate","ruby"]
|
||||
|
||||
interface WebsiteThemeContextValue {
|
||||
websiteTheme: string
|
||||
setWebsiteTheme: (theme: string) => Promise<void>
|
||||
colorTheme: string
|
||||
setColorTheme: (theme: string) => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
@@ -27,9 +32,19 @@ function applyWebsiteTheme(theme: string) {
|
||||
root.className = classes.join(" ").trim()
|
||||
}
|
||||
|
||||
function getStoredTheme(): string | null {
|
||||
function applyColorTheme(theme: string) {
|
||||
const root = document.documentElement
|
||||
const classes = root.className.split(" ").filter((c) => !colorThemes.includes(c))
|
||||
if (theme !== "default") {
|
||||
classes.push(theme)
|
||||
}
|
||||
root.className = classes.join(" ").trim()
|
||||
localStorage.setItem(COLOR_THEME_KEY, theme)
|
||||
}
|
||||
|
||||
function getStored(key: string): string | null {
|
||||
if (typeof window === "undefined") return null
|
||||
return localStorage.getItem(WEBSITE_THEME_KEY)
|
||||
return localStorage.getItem(key)
|
||||
}
|
||||
|
||||
function storeTheme(theme: string) {
|
||||
@@ -38,27 +53,41 @@ function storeTheme(theme: string) {
|
||||
}
|
||||
|
||||
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
|
||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
|
||||
const [colorTheme, setColorThemeState] = useState<string>("default")
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = getStoredTheme()
|
||||
if (stored) {
|
||||
setWebsiteThemeState(stored)
|
||||
applyWebsiteTheme(stored)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
fetch("/api/settings/website-theme")
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
const theme = data?.websiteTheme || "spidey"
|
||||
setWebsiteThemeState(theme)
|
||||
storeTheme(theme)
|
||||
applyWebsiteTheme(theme)
|
||||
const wt = data?.websiteTheme || "default"
|
||||
setWebsiteThemeState(wt)
|
||||
storeTheme(wt)
|
||||
applyWebsiteTheme(wt)
|
||||
|
||||
if (data?.colorTheme) {
|
||||
setColorThemeState(data.colorTheme)
|
||||
applyColorTheme(data.colorTheme)
|
||||
} else {
|
||||
const stored = getStored(COLOR_THEME_KEY)
|
||||
if (stored) {
|
||||
setColorThemeState(stored)
|
||||
applyColorTheme(stored)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
applyWebsiteTheme("spidey")
|
||||
const stored = getStored(WEBSITE_THEME_KEY)
|
||||
const theme = stored || "default"
|
||||
setWebsiteThemeState(theme)
|
||||
applyWebsiteTheme(theme)
|
||||
|
||||
const storedColor = getStored(COLOR_THEME_KEY)
|
||||
if (storedColor) {
|
||||
setColorThemeState(storedColor)
|
||||
applyColorTheme(storedColor)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
@@ -78,8 +107,22 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setColorTheme = useCallback(async (theme: string) => {
|
||||
setColorThemeState(theme)
|
||||
applyColorTheme(theme)
|
||||
try {
|
||||
await fetch("/api/settings/website-theme", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ colorTheme: theme }),
|
||||
})
|
||||
} catch {
|
||||
console.warn("Failed to persist color theme")
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, loading }}>
|
||||
<WebsiteThemeContext.Provider value={{ websiteTheme, setWebsiteTheme, colorTheme, setColorTheme, loading }}>
|
||||
{children}
|
||||
</WebsiteThemeContext.Provider>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/* ============================================================================
|
||||
AI Assistant Page — Animations & Keyframes
|
||||
============================================================================ */
|
||||
|
||||
/* Robot walking animation (boot state) */
|
||||
@keyframes walk {
|
||||
0%, 100% { transform: translateX(0) translateY(0); }
|
||||
25% { transform: translateX(12px) translateY(-4px); }
|
||||
50% { transform: translateX(24px) translateY(0); }
|
||||
75% { transform: translateX(12px) translateY(-4px); }
|
||||
}
|
||||
|
||||
@keyframes legLeft {
|
||||
0%, 100% { transform: rotate(-10deg); }
|
||||
50% { transform: rotate(10deg); }
|
||||
}
|
||||
|
||||
@keyframes legRight {
|
||||
0%, 100% { transform: rotate(10deg); }
|
||||
50% { transform: rotate(-10deg); }
|
||||
}
|
||||
|
||||
@keyframes armLeft {
|
||||
0%, 100% { transform: rotate(15deg); }
|
||||
50% { transform: rotate(-15deg); }
|
||||
}
|
||||
|
||||
@keyframes armRight {
|
||||
0%, 100% { transform: rotate(-15deg); }
|
||||
50% { transform: rotate(15deg); }
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 45%, 55%, 100% { height: 4px; }
|
||||
50% { height: 1px; }
|
||||
}
|
||||
|
||||
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||
|
||||
/* Welcome screen float-in animation */
|
||||
@keyframes float-in {
|
||||
0% { opacity: 0; transform: translateY(12px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.float-in { animation: float-in 0.4s ease-out both; }
|
||||
|
||||
/* Typing indicator bouncing dots */
|
||||
@keyframes bounce-dot {
|
||||
0%, 60%, 100% { transform: translateY(0); }
|
||||
30% { transform: translateY(-6px); }
|
||||
}
|
||||
|
||||
.dot-1 { animation: bounce-dot 1.2s infinite 0ms; }
|
||||
.dot-2 { animation: bounce-dot 1.2s infinite 150ms; }
|
||||
.dot-3 { animation: bounce-dot 1.2s infinite 300ms; }
|
||||
+6
-1
@@ -110,13 +110,14 @@ export interface Conversation {
|
||||
messages: ChatMessage[];
|
||||
}
|
||||
|
||||
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other";
|
||||
export type EventType = "call" | "follow_up" | "website_creation";
|
||||
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;
|
||||
@@ -129,6 +130,10 @@ export interface CalendarEvent {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ const config: Config = {
|
||||
border: "hsl(var(--sidebar-border))",
|
||||
ring: "hsl(var(--sidebar-ring))",
|
||||
},
|
||||
blue: "hsl(var(--color-blue))",
|
||||
teal: "hsl(var(--color-teal))",
|
||||
"red-tint": "hsl(var(--color-red-tint))",
|
||||
"blue-tint": "hsl(var(--color-blue-tint))",
|
||||
"teal-tint": "hsl(var(--color-teal-tint))",
|
||||
avatar: "hsl(var(--color-avatar))",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "admin@coastit.co.za", password: "AdminAccess@2026" },
|
||||
superadmin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
dev: { email: "dev@coastit.co.za", password: "DevTesting@2026" },
|
||||
}
|
||||
const cred = credentials[role]
|
||||
if (!cred) return null
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
return res.headers.get("set-cookie")?.split(";")[0]
|
||||
}
|
||||
|
||||
describe("Bug Reports", () => {
|
||||
it("allows any authenticated user to submit a bug report", async () => {
|
||||
const cookie = await loginAs("dev")
|
||||
if (!cookie) return
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ title: "Test bug", description: "This is a test bug report" }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated bug report submission", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/bug-reports`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title: "Test", description: "Test" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Run: npm run dev (starts the server on port 3006)
|
||||
// Then: npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
async function loginAs(role: string) {
|
||||
const credentials: Record<string, { email: string; password: string }> = {
|
||||
admin: { email: "superadmin@coastit.co.za", password: "SuperAdmin@2026" },
|
||||
sales: { email: "sales@coastit.co.za", password: "SalesAccess@2026" },
|
||||
}
|
||||
const cred = credentials[role] || credentials.admin
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(cred),
|
||||
})
|
||||
if (res.status !== 200) return null
|
||||
const setCookie = res.headers.get("set-cookie")
|
||||
return setCookie?.split(";")[0] // return the raw cookie value
|
||||
}
|
||||
|
||||
describe("Leads API", () => {
|
||||
it("creates a lead when authenticated", async () => {
|
||||
const cookie = await loginAs("sales")
|
||||
if (!cookie) return // skip if DB not available
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({
|
||||
company_name: "Test Company",
|
||||
contact_name: "Test Contact",
|
||||
email: "test@example.com",
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// Cleanup
|
||||
const data = await res.json()
|
||||
if (data?.id) {
|
||||
await fetch(`${API_BASE}/api/leads/${data.id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Cookie: cookie },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it("rejects unauthenticated lead creation", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ company_name: "Test", contact_name: "Test", email: "test@test.com" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest"
|
||||
|
||||
// These tests require a running PostgreSQL database with the CRM schema.
|
||||
// Set DATABASE_URL env var or the default local connection will be used.
|
||||
//
|
||||
// Run: DATABASE_URL=postgresql://postgres:postgres@localhost:5432/crm_test npx vitest run
|
||||
|
||||
const API_BASE = "http://localhost:3006"
|
||||
|
||||
function skipIfNoDb() {
|
||||
if (!process.env.CI && !process.env.DATABASE_URL?.includes("crm_test")) {
|
||||
console.warn("Skipping DB-dependent tests. Set DATABASE_URL to a test database.")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
describe("Login", () => {
|
||||
it("returns 401 with wrong password", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "superadmin@coastit.co.za", password: "wrong" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("returns 400 with empty body", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it("returns 401 for non-existent user", async () => {
|
||||
if (skipIfNoDb()) return
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "nobody@example.com", password: "anything" }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Authorization", () => {
|
||||
it("rejects unauthenticated requests to protected routes", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/leads`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it("rejects unauthenticated requests to dashboard", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/dashboard`, { headers: { "Content-Type": "application/json" } })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { signToken, verifyToken } from "@/lib/jwt"
|
||||
|
||||
process.env.JWT_SECRET = "test-secret-that-is-at-least-32-chars-long-for-security"
|
||||
|
||||
describe("JWT", () => {
|
||||
it("signs and verifies a valid token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
expect(token).toBeTruthy()
|
||||
expect(typeof token).toBe("string")
|
||||
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).not.toBeNull()
|
||||
expect(payload!.userId).toBe("user-1")
|
||||
expect(payload!.role).toBe("admin")
|
||||
})
|
||||
|
||||
it("rejects a tampered token", async () => {
|
||||
const token = await signToken({ userId: "user-1", role: "admin" })
|
||||
const tampered = token.slice(0, -5) + "XXXXX"
|
||||
const payload = await verifyToken(tampered)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects an expired token", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const { getJWTSecret } = await import("@/lib/jwt")
|
||||
const expiredToken = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("0s")
|
||||
.sign(getJWTSecret())
|
||||
const payload = await verifyToken(expiredToken)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("rejects a token with invalid signature", async () => {
|
||||
const { SignJWT } = await import("jose")
|
||||
const wrongSecret = new TextEncoder().encode("different-secret-key-for-signing-purposes-only")
|
||||
const token = await new SignJWT({ userId: "user-1", role: "admin" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("24h")
|
||||
.setIssuedAt()
|
||||
.sign(wrongSecret)
|
||||
const payload = await verifyToken(token)
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for empty token", async () => {
|
||||
const payload = await verifyToken("")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for garbage token", async () => {
|
||||
const payload = await verifyToken("this.is.not.a.jwt")
|
||||
expect(payload).toBeNull()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user