Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d77ff2b965 | |||
| 0bc3ca58ed | |||
| d793604e92 | |||
| bc83af8e00 | |||
| 80dee367e8 | |||
| da99b695be | |||
| dd6767980a | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| c0cb715ced | |||
| 1269f6cce8 | |||
| 370f935cbb | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| e8d80c3a16 | |||
| 77cccba8a1 | |||
| 7a5e833722 | |||
| 1e8f979cf9 | |||
| 98145f0264 | |||
| 7a722a7d7e | |||
| 61e9fac352 | |||
| 878627a6ba | |||
| 2c3a8e5333 | |||
| d870fb3ac7 | |||
| f6d1d91fc6 | |||
| bc3e345a27 | |||
| 3c0e7d76ca | |||
| 39fb39db12 | |||
| cd37d5a987 | |||
| d138c60203 | |||
| 4d7a59c278 | |||
| aac9817ee7 | |||
| fa97abb5b6 | |||
| 3998285fd5 | |||
| 4c1db42873 | |||
| c428435f2f | |||
| 886a7efe91 | |||
| 1b5f244f28 | |||
| 46a3216386 | |||
| 8aba5c9ef4 | |||
| 06181625c3 | |||
| 08eba574f8 | |||
| a876f2d769 | |||
| fbb37d6777 | |||
| 214c4a1852 | |||
| c669ec0d04 | |||
| bf6fbe3ee4 | |||
| d6534eb338 | |||
| a08f23ab0f | |||
| bccba1434b | |||
| fe09d15359 | |||
| 2b8f2e7b59 | |||
| fb99b49686 | |||
| 75746f4cab | |||
| a022e3cca3 | |||
| 68483e9b60 | |||
| 21868fe336 | |||
| 77b246b565 | |||
| 34e64fe6c7 | |||
| ffa595451e | |||
| bd581739f9 | |||
| ec4de03cf5 | |||
| dfba947aa4 | |||
| 9bd795e39a | |||
| 66ae711cf0 | |||
| 827b0598e6 | |||
| 650116872b | |||
| 5e3c12fa2d | |||
| 7a76841309 | |||
| 283c06f0cf | |||
| 60892d6151 | |||
| 131d4c8f94 | |||
| 1f448320be | |||
| 9ce9506e8e | |||
| ed2e1fc64d | |||
| 5feb95187c | |||
| a4d2ad143b | |||
| da702d6beb |
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build & Auto-Repair
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
run: npx playwright install chromium firefox
|
||||||
|
|
||||||
|
- name: Build check
|
||||||
|
id: build
|
||||||
|
continue-on-error: true
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Run self-healing setup
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
run: node scripts/setup.mjs --self-heal
|
||||||
|
|
||||||
|
- name: Run code repair agent
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
env:
|
||||||
|
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
|
||||||
|
run: node scripts/code-repair-agent.mjs --ci
|
||||||
|
|
||||||
|
- name: Re-check build after repair
|
||||||
|
id: rebuild
|
||||||
|
continue-on-error: true
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Commit fixes
|
||||||
|
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
git config user.name "CRM Repair Bot"
|
||||||
|
git config user.email "bot@coastit.co.za"
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
|
||||||
|
git push
|
||||||
|
|
||||||
|
- name: Build failed — report
|
||||||
|
if: steps.rebuild.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
echo "::error::Build still failing after auto-repair. Manual intervention required."
|
||||||
|
exit 1
|
||||||
+9
-3
@@ -47,9 +47,6 @@ error-log
|
|||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
# rust
|
|
||||||
rust-ai/target/
|
|
||||||
|
|
||||||
# python
|
# python
|
||||||
browser-use-service/venv/
|
browser-use-service/venv/
|
||||||
browser-use-service/__pycache__/
|
browser-use-service/__pycache__/
|
||||||
@@ -58,3 +55,12 @@ browser-use-service/.env
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.git
|
||||||
|
.git_bak
|
||||||
|
node_modules
|
||||||
|
target
|
||||||
|
# rust build artifacts
|
||||||
|
rust-ai/target/
|
||||||
|
|
||||||
|
# browser-use-service generated files
|
||||||
|
browser-use-service/*.txt
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
|
||||||
|
"templates": {
|
||||||
|
"data/stickers": {
|
||||||
|
"template": "stickers.ts",
|
||||||
|
"description": "Emoji-based sticker packs for media picker"
|
||||||
|
},
|
||||||
|
"data/constants": {
|
||||||
|
"template": "constants.ts",
|
||||||
|
"description": "App-wide constants (lead statuses, roles, event types)"
|
||||||
|
},
|
||||||
|
"lib/utils": {
|
||||||
|
"template": "utils.ts",
|
||||||
|
"description": "Utility functions (cn, formatters, helpers)"
|
||||||
|
},
|
||||||
|
"types/index": {
|
||||||
|
"template": "types-index.ts",
|
||||||
|
"description": "Core type definitions (User, Lead, DashboardStats)"
|
||||||
|
},
|
||||||
|
"hooks/use-media": {
|
||||||
|
"template": "hooks/use-media.ts",
|
||||||
|
"description": "Media query hook"
|
||||||
|
},
|
||||||
|
"hooks/use-debounce": {
|
||||||
|
"template": "hooks/use-debounce.ts",
|
||||||
|
"description": "Debounce hook"
|
||||||
|
},
|
||||||
|
"hooks/use-local-storage": {
|
||||||
|
"template": "hooks/use-local-storage.ts",
|
||||||
|
"description": "LocalStorage hook with SSR safety"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fallbackTemplates": {
|
||||||
|
"@/data/*": "data-generic.ts",
|
||||||
|
"@/lib/*": "lib-generic.ts",
|
||||||
|
"@/hooks/*": "hook-generic.ts",
|
||||||
|
"@/types/*": "types-generic.ts",
|
||||||
|
"@/components/*": "component-generic.tsx",
|
||||||
|
"@/utils/*": "utils-generic.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Component Template ────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/components/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export default function GenericComponent() {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ── App Constants ────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
|
||||||
|
|
||||||
|
export const APP_NAME = "CoastIT CRM";
|
||||||
|
export const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export const LEAD_STATUSES = {
|
||||||
|
new: { label: "New", color: "bg-blue-500" },
|
||||||
|
contacted: { label: "Contacted", color: "bg-yellow-500" },
|
||||||
|
qualified: { label: "Qualified", color: "bg-purple-500" },
|
||||||
|
proposal: { label: "Proposal", color: "bg-orange-500" },
|
||||||
|
won: { label: "Won", color: "bg-green-500" },
|
||||||
|
lost: { label: "Lost", color: "bg-red-500" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
super_admin: { label: "Super Admin", level: 1 },
|
||||||
|
admin: { label: "Admin", level: 2 },
|
||||||
|
sales: { label: "Sales", level: 3 },
|
||||||
|
dev: { label: "Developer", level: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"meeting",
|
||||||
|
"call",
|
||||||
|
"email",
|
||||||
|
"task",
|
||||||
|
"note",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const EVENT_STATUSES = [
|
||||||
|
"scheduled",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
"rescheduled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const AI_MODELS = {
|
||||||
|
chat: "llama3.2:3b",
|
||||||
|
scraper: "dolphin-llama3:8b",
|
||||||
|
coder: "qwen2.5-coder:1.5b-base",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PAGINATION = {
|
||||||
|
defaultLimit: 50,
|
||||||
|
maxLimit: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DATE_FORMATS = {
|
||||||
|
short: "MMM d, yyyy",
|
||||||
|
long: "MMMM d, yyyy",
|
||||||
|
time: "h:mm a",
|
||||||
|
datetime: "MMM d, yyyy h:mm a",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// ── Generic Data Module Template ─────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/data/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export interface DataItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataItems(): DataItem[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataItem(id: string): DataItem | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// ── Generic Hook Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/hooks/* imports. Customize as needed.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// ── useDebounce Hook ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ── useLocalStorage Hook ──────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
if (item) setStoredValue(JSON.parse(item));
|
||||||
|
} catch {
|
||||||
|
// corrupted data — use initial value
|
||||||
|
}
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
const setValue = (value: T | ((prev: T) => T)) => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
} catch {
|
||||||
|
// storage full or disabled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ── useMedia Hook ─────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useMedia(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
setMatches(mql.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Lib Module Template ──────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/lib/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export function libFunction(): void {
|
||||||
|
// Placeholder
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// ── Sticker Packs ─────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||||
|
|
||||||
|
export interface Sticker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
svg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stickers: Sticker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionStickers: Sticker[] = [
|
||||||
|
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
|
||||||
|
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
|
||||||
|
{ id: "heart", name: "Heart", emoji: "❤️" },
|
||||||
|
{ id: "laugh", name: "Laugh", emoji: "😂" },
|
||||||
|
{ id: "wow", name: "Wow", emoji: "😮" },
|
||||||
|
{ id: "sad", name: "Sad", emoji: "😢" },
|
||||||
|
{ id: "angry", name: "Angry", emoji: "😡" },
|
||||||
|
{ id: "clap", name: "Clap", emoji: "👏" },
|
||||||
|
{ id: "fire", name: "Fire", emoji: "🔥" },
|
||||||
|
{ id: "100", name: "100", emoji: "💯" },
|
||||||
|
{ id: "eyes", name: "Eyes", emoji: "👀" },
|
||||||
|
{ id: "pray", name: "Pray", emoji: "🙏" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const animalStickers: Sticker[] = [
|
||||||
|
{ id: "cat", name: "Cat", emoji: "🐱" },
|
||||||
|
{ id: "dog", name: "Dog", emoji: "🐶" },
|
||||||
|
{ id: "panda", name: "Panda", emoji: "🐼" },
|
||||||
|
{ id: "fox", name: "Fox", emoji: "🦊" },
|
||||||
|
{ id: "bear", name: "Bear", emoji: "🐻" },
|
||||||
|
{ id: "koala", name: "Koala", emoji: "🐨" },
|
||||||
|
{ id: "tiger", name: "Tiger", emoji: "🐯" },
|
||||||
|
{ id: "lion", name: "Lion", emoji: "🦁" },
|
||||||
|
{ id: "monkey", name: "Monkey", emoji: "🐵" },
|
||||||
|
{ id: "frog", name: "Frog", emoji: "🐸" },
|
||||||
|
{ id: "penguin", name: "Penguin", emoji: "🐧" },
|
||||||
|
{ id: "bird", name: "Bird", emoji: "🐦" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const foodStickers: Sticker[] = [
|
||||||
|
{ id: "pizza", name: "Pizza", emoji: "🍕" },
|
||||||
|
{ id: "burger", name: "Burger", emoji: "🍔" },
|
||||||
|
{ id: "taco", name: "Taco", emoji: "🌮" },
|
||||||
|
{ id: "sushi", name: "Sushi", emoji: "🍣" },
|
||||||
|
{ id: "ramen", name: "Ramen", emoji: "🍜" },
|
||||||
|
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
|
||||||
|
{ id: "cake", name: "Cake", emoji: "🍰" },
|
||||||
|
{ id: "coffee", name: "Coffee", emoji: "☕" },
|
||||||
|
{ id: "beer", name: "Beer", emoji: "🍺" },
|
||||||
|
{ id: "wine", name: "Wine", emoji: "🍷" },
|
||||||
|
{ id: "donut", name: "Donut", emoji: "🍩" },
|
||||||
|
{ id: "cookie", name: "Cookie", emoji: "🍪" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activityStickers: Sticker[] = [
|
||||||
|
{ id: "football", name: "Football", emoji: "⚽" },
|
||||||
|
{ id: "basketball", name: "Basketball", emoji: "🏀" },
|
||||||
|
{ id: "tennis", name: "Tennis", emoji: "🎾" },
|
||||||
|
{ id: "running", name: "Running", emoji: "🏃" },
|
||||||
|
{ id: "swimming", name: "Swimming", emoji: "🏊" },
|
||||||
|
{ id: "cycling", name: "Cycling", emoji: "🚴" },
|
||||||
|
{ id: "gym", name: "Gym", emoji: "🏋️" },
|
||||||
|
{ id: "yoga", name: "Yoga", emoji: "🧘" },
|
||||||
|
{ id: "music", name: "Music", emoji: "🎵" },
|
||||||
|
{ id: "party", name: "Party", emoji: "🎉" },
|
||||||
|
{ id: "gift", name: "Gift", emoji: "🎁" },
|
||||||
|
{ id: "trophy", name: "Trophy", emoji: "🏆" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stickerPacks: StickerPack[] = [
|
||||||
|
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
|
||||||
|
{ id: "animals", name: "Animals", stickers: animalStickers },
|
||||||
|
{ id: "food", name: "Food & Drink", stickers: foodStickers },
|
||||||
|
{ id: "activities", name: "Activities", stickers: activityStickers },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return stickerPacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerPack(id: string): StickerPack | undefined {
|
||||||
|
return stickerPacks.find((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
|
||||||
|
const pack = getStickerPack(packId);
|
||||||
|
return pack?.stickers.find((s) => s.id === stickerId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// ── Generic Types Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/types/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export type GenericType = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface GenericInterface {
|
||||||
|
id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// ── Type Definitions ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
|
||||||
|
|
||||||
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
role: UserRole;
|
||||||
|
active: boolean;
|
||||||
|
avatar: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
contactName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
assignedUserId: string | null;
|
||||||
|
assignedUser: User | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadStatus =
|
||||||
|
| "new"
|
||||||
|
| "contacted"
|
||||||
|
| "qualified"
|
||||||
|
| "proposal"
|
||||||
|
| "won"
|
||||||
|
| "lost";
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalLeads: number;
|
||||||
|
newLeads: number;
|
||||||
|
contactedLeads: number;
|
||||||
|
qualifiedLeads: number;
|
||||||
|
proposalLeads: number;
|
||||||
|
wonLeads: number;
|
||||||
|
lostLeads: number;
|
||||||
|
conversionRate: number;
|
||||||
|
monthlyBreakdown: {
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
contacted: number;
|
||||||
|
qualified: number;
|
||||||
|
proposal: number;
|
||||||
|
won: number;
|
||||||
|
lost: number;
|
||||||
|
}[];
|
||||||
|
leadsPerMonth: { label: string; leads: number; won: number }[];
|
||||||
|
recentLeads: Lead[];
|
||||||
|
statusDistribution: { name: string; value: number; color: string }[];
|
||||||
|
periodLabel: string;
|
||||||
|
trends: Record<string, { pct: number; up: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
id: string;
|
||||||
|
leadId: string;
|
||||||
|
userId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorAvatar: string;
|
||||||
|
authorRole: UserRole;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "lead_created"
|
||||||
|
| "lead_status_changed"
|
||||||
|
| "lead_assigned"
|
||||||
|
| "chat_message"
|
||||||
|
| "note_added"
|
||||||
|
| "event_scheduled";
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
senderAvatar: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
participants: { id: string; name: string; avatar: string; role: string }[];
|
||||||
|
lastMessage: string;
|
||||||
|
lastMessageTime: string;
|
||||||
|
unread: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventType = "meeting" | "call" | "email" | "task" | "note";
|
||||||
|
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
participantId: string | null;
|
||||||
|
developerId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
conversationId: string | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
participantNotes: string | null;
|
||||||
|
eventType: EventType;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
status: EventStatus;
|
||||||
|
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
lead: { id: string; companyName: string; contactName: string } | null;
|
||||||
|
clientName: string | null;
|
||||||
|
clientEmail: string | null;
|
||||||
|
clientPhone: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Utils Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/utils/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export function utilsFunction(): void {
|
||||||
|
// Placeholder
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// ── Utility Functions ────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
|
||||||
|
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
limit: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle = false;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (!inThrottle) {
|
||||||
|
fn(...args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => (inThrottle = false), limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
|
||||||
|
return fn().catch((err) =>
|
||||||
|
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Spidey Theme — Baseline Design Tokens
|
||||||
|
This is the current website appearance captured as a reusable CSS theme.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-spidey body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
repeating-conic-gradient(
|
||||||
|
from 10deg,
|
||||||
|
transparent 0deg 10deg,
|
||||||
|
rgba(220, 38, 38, 0.04) 10deg 12deg
|
||||||
|
),
|
||||||
|
repeating-radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
transparent 0,
|
||||||
|
transparent 30px,
|
||||||
|
rgba(37, 99, 235, 0.03) 30px,
|
||||||
|
transparent 32px
|
||||||
|
),
|
||||||
|
radial-gradient(2px 2px at 10% 20%, rgba(220,38,38,0.35) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 30% 80%, rgba(37,99,235,0.35) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 50% 30%, rgba(220,38,38,0.3) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 70% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 85% 15%, rgba(220,38,38,0.35) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 20% 50%, rgba(37,99,235,0.25) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 65% 85%, rgba(220,38,38,0.25) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 40% 10%, rgba(37,99,235,0.3) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 90% 75%, rgba(220,38,38,0.25) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 5% 60%, rgba(37,99,235,0.3) 0%, transparent 100%),
|
||||||
|
radial-gradient(2px 2px at 55% 55%, rgba(220,38,38,0.2) 0%, transparent 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: drift 60s linear infinite;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"id": "spidey",
|
||||||
|
"name": "Spidey",
|
||||||
|
"description": "Current website appearance — dark theme with red accents",
|
||||||
|
"type": "dark",
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"foreground": "hsl(210, 40%, 98%)",
|
||||||
|
"card": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"cardForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"popover": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"popoverForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"primary": "hsl(0, 100%, 53%)",
|
||||||
|
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
|
||||||
|
"secondary": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"secondaryForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"muted": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
|
||||||
|
"accent": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"accentForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"destructive": "hsl(0, 62.8%, 30.6%)",
|
||||||
|
"destructiveForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"border": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"input": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"ring": "hsl(0, 100%, 53%)",
|
||||||
|
"sidebar": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"sidebarForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"sidebarPrimary": "hsl(0, 100%, 53%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
|
"sidebarRing": "hsl(0, 100%, 53%)"
|
||||||
|
},
|
||||||
|
"borderRadius": "0.5rem",
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, sans-serif",
|
||||||
|
"headingFont": "Inter, sans-serif"
|
||||||
|
},
|
||||||
|
"keyColors": {
|
||||||
|
"primary": "#FF0000",
|
||||||
|
"background": "#0a0a0f",
|
||||||
|
"card": "#141414",
|
||||||
|
"sidebar": "#0a0a0f",
|
||||||
|
"border": "#1a1a24",
|
||||||
|
"text": "#e8e8ef",
|
||||||
|
"mutedText": "#888888"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT u.id, u.first_name, u.last_name, u.email
|
||||||
|
FROM users u
|
||||||
|
WHERE u.deleted_at IS NULL AND u.id != $1
|
||||||
|
ORDER BY u.first_name ASC`,
|
||||||
|
[user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
const users = result.rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: `${r.first_name} ${r.last_name}`,
|
||||||
|
email: r.email,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Event users error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { buildIcs } from "@/lib/ics"
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
|
||||||
|
e.start_time, e.end_time, e.duration_minutes, e.status,
|
||||||
|
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
|
||||||
|
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
|
||||||
|
FROM scheduled_events e
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
LEFT JOIN users p ON p.id = e.participant_id
|
||||||
|
WHERE e.id = $1 AND e.user_id = $2`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
|
||||||
|
const icsContent = buildIcs({
|
||||||
|
uid: r.id,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description || undefined,
|
||||||
|
startTime: new Date(r.start_time),
|
||||||
|
endTime: r.end_time ? new Date(r.end_time) : undefined,
|
||||||
|
durationMinutes: r.duration_minutes || undefined,
|
||||||
|
organizerName: `${r.user_first} ${r.user_last}`,
|
||||||
|
organizerEmail: r.user_email,
|
||||||
|
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
|
||||||
|
attendeeEmail: r.p_email || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(icsContent, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ICS GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { sendEventRescheduled } from "@/lib/email"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = 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,
|
||||||
|
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
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
LEFT JOIN users p ON p.id = e.participant_id
|
||||||
|
WHERE e.id = $1`,
|
||||||
|
[id],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const old = existing.rows[0]
|
||||||
|
const isCreator = old.user_id === user.id
|
||||||
|
const isParticipant = old.participant_id === user.id
|
||||||
|
|
||||||
|
if (!isCreator && !isParticipant) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCreator && (
|
||||||
|
(title !== undefined && title !== old.title) ||
|
||||||
|
(eventType !== undefined && eventType !== old.event_type) ||
|
||||||
|
(startTime !== undefined && startTime !== old.start_time) ||
|
||||||
|
(endTime !== undefined && endTime !== old.end_time) ||
|
||||||
|
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
|
||||||
|
(participantId !== undefined && participantId !== old.participant_id)
|
||||||
|
)) {
|
||||||
|
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE scheduled_events SET
|
||||||
|
title = COALESCE($1, title),
|
||||||
|
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),
|
||||||
|
status = COALESCE($8, status),
|
||||||
|
participant_id = COALESCE($9, participant_id),
|
||||||
|
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`,
|
||||||
|
[
|
||||||
|
title || null,
|
||||||
|
description ?? null,
|
||||||
|
participantNotes ?? null,
|
||||||
|
eventType || null,
|
||||||
|
startTime || null,
|
||||||
|
endTime ?? null,
|
||||||
|
durationMinutes ?? null,
|
||||||
|
status || null,
|
||||||
|
participantId ?? null,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (isChanged && r.participant_id && r.participant_id !== user.id) {
|
||||||
|
sendEventRescheduled({
|
||||||
|
creatorName: `${user.firstName} ${user.lastName}`,
|
||||||
|
creatorEmail: user.email,
|
||||||
|
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
|
||||||
|
participantEmail: old.p_email || null,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description || undefined,
|
||||||
|
eventType: r.event_type,
|
||||||
|
startTime: r.start_time,
|
||||||
|
endTime: r.end_time || undefined,
|
||||||
|
durationMinutes: r.duration_minutes || undefined,
|
||||||
|
}).catch((err) => console.error("Reschedule email error:", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorResult = await query(
|
||||||
|
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
|
||||||
|
ur.role_id, r.name AS role_name, r.display_name AS role_display
|
||||||
|
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.id = $1`,
|
||||||
|
[old.user_id],
|
||||||
|
)
|
||||||
|
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
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
lead: null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const existing = await query(
|
||||||
|
`SELECT user_id FROM scheduled_events WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.rows[0].user_id !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events DELETE error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { sendEventConfirmation } from "@/lib/email"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const start = searchParams.get("start")
|
||||||
|
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,
|
||||||
|
e.title, e.description, e.participant_notes, e.event_type,
|
||||||
|
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
|
||||||
|
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,
|
||||||
|
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 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`
|
||||||
|
const params: unknown[] = []
|
||||||
|
let idx = 1
|
||||||
|
|
||||||
|
if (user.role !== "super_admin") {
|
||||||
|
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
|
||||||
|
params.push(user.id)
|
||||||
|
idx++
|
||||||
|
} else {
|
||||||
|
sql += ` WHERE 1=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start) {
|
||||||
|
sql += ` AND e.start_time >= $${idx}`
|
||||||
|
params.push(start)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end) {
|
||||||
|
sql += ` AND e.start_time <= $${idx}`
|
||||||
|
params.push(end)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
sql += ` AND e.status = $${idx}`
|
||||||
|
params.push(status)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY e.start_time ASC"
|
||||||
|
|
||||||
|
const result = await query(sql, params, user.id)
|
||||||
|
|
||||||
|
const events = result.rows.map((r: any) => ({
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ events })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const {
|
||||||
|
participantId, leadId, conversationId,
|
||||||
|
title, description, eventType,
|
||||||
|
startTime, endTime, durationMinutes,
|
||||||
|
} = await request.json()
|
||||||
|
|
||||||
|
if (!title || !startTime) {
|
||||||
|
return NextResponse.json({ error: "Title and start time are required" }, { 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)`,
|
||||||
|
[
|
||||||
|
participantId,
|
||||||
|
"event_scheduled",
|
||||||
|
`Meeting scheduled: ${title}`,
|
||||||
|
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`,
|
||||||
|
`/calendar`,
|
||||||
|
r.id,
|
||||||
|
"scheduled_event",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const participantResult = participantId ? await query(
|
||||||
|
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
|
||||||
|
[participantId],
|
||||||
|
) : null
|
||||||
|
const participant = participantResult?.rows[0] || null
|
||||||
|
|
||||||
|
sendEventConfirmation({
|
||||||
|
creatorName: `${user.firstName} ${user.lastName}`,
|
||||||
|
creatorEmail: user.email,
|
||||||
|
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
|
||||||
|
participantEmail: participant?.email || null,
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
eventType: eventType || "meeting",
|
||||||
|
startTime,
|
||||||
|
endTime: endTime || undefined,
|
||||||
|
durationMinutes: durationMinutes || undefined,
|
||||||
|
}).catch((err) => console.error("Email error:", err))
|
||||||
|
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
},
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_minutes INT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
|
||||||
|
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 013: Row-Level Security for scheduled_events
|
||||||
|
-- ============================================================================
|
||||||
|
-- Enables RLS on the scheduled_events table so that users can only see
|
||||||
|
-- their own events at the database level. This is defense-in-depth:
|
||||||
|
-- the API already filters by user_id, but RLS ensures data isolation
|
||||||
|
-- even if there's a bug in the application layer.
|
||||||
|
--
|
||||||
|
-- The policy allows all operations when app.current_user_id is NULL
|
||||||
|
-- (backward-compatible fallback for queries that don't set the variable).
|
||||||
|
-- When the variable IS set, only rows matching the user's ID are visible.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
|
||||||
|
CREATE POLICY scheduled_events_user_policy ON scheduled_events
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
user_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR current_setting('app.current_user_id', true) IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 015: Fix RLS for scheduled_events to include participant_id
|
||||||
|
-- ============================================================================
|
||||||
|
-- Previously, the RLS policy only checked user_id, which meant participants
|
||||||
|
-- could not see events at the database level (the app-layer WHERE clause
|
||||||
|
-- did the filtering, but RLS was defense-in-depth that missed this case).
|
||||||
|
--
|
||||||
|
-- This policy also allows app.current_user_id IS NULL for backward compat
|
||||||
|
-- with queries that don't set the session variable.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
|
||||||
|
CREATE POLICY scheduled_events_user_policy ON scheduled_events
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
user_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR participant_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR current_setting('app.current_user_id', true) IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
+228
-16
@@ -1,12 +1,26 @@
|
|||||||
|
// ── CRM AI Server ──────────────────────────────────────────────────
|
||||||
|
// Provides:
|
||||||
|
// - Chat API (POST /ai/chat) — routes user messages to Ollama for sales coaching
|
||||||
|
// - Setup wizard endpoints (GET /setup/status, POST /setup/profile, etc.)
|
||||||
|
// - Combined /status endpoint for splash page health polling
|
||||||
|
// - Configuration routes (GET/POST /ai/instructions, GET /ai/jobs)
|
||||||
|
// - Model pull support (POST /setup/ollama/pull)
|
||||||
|
//
|
||||||
|
// This is a zero-dependency Node.js HTTP server (no Express needed).
|
||||||
|
|
||||||
import http from "node:http"
|
import http from "node:http"
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const ROOT = path.resolve(__dirname, "..")
|
const ROOT = path.resolve(__dirname, "..")
|
||||||
|
|
||||||
// ── Load .env.local ──────────────────────────────────────────────
|
// ── Load .env.local ──────────────────────────────────────────────
|
||||||
|
// Reads key=value pairs and sets them as process.env so they're
|
||||||
|
// available throughout the server. Ignores comments and blank lines.
|
||||||
|
// Values with matching quotes are unquoted.
|
||||||
try {
|
try {
|
||||||
const envPath = path.join(ROOT, ".env.local")
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
const envContent = fs.readFileSync(envPath, "utf-8")
|
const envContent = fs.readFileSync(envPath, "utf-8")
|
||||||
@@ -22,7 +36,7 @@ try {
|
|||||||
}
|
}
|
||||||
console.log("Loaded .env.local")
|
console.log("Loaded .env.local")
|
||||||
} catch {
|
} catch {
|
||||||
// .env.local may not exist, ignore
|
// .env.local may not exist (first run), which is fine
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Config from env ─────────────────────────────────────────────
|
// ── Config from env ─────────────────────────────────────────────
|
||||||
@@ -30,11 +44,21 @@ const PORT = parseInt(process.env.AI_PORT || "3001", 10)
|
|||||||
const HOST = process.env.AI_HOST || "0.0.0.0"
|
const HOST = process.env.AI_HOST || "0.0.0.0"
|
||||||
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || "http://localhost:11434"
|
||||||
const MODEL = process.env.AI_MODEL || "llama3.2:3b"
|
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 DATABASE_URL = process.env.DATABASE_URL
|
||||||
const JOBS_PATH = process.env.JOBS_PATH || path.join(ROOT, "data", "ai", "jobs.jsonl")
|
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")
|
const AI_MD_PATH = process.env.AI_MD_PATH || path.join(ROOT, "data", "ai", "ai.md")
|
||||||
|
|
||||||
|
// ── Setup state ──────────────────────────────────────────────────
|
||||||
|
// Tracks the Ollama model pull process so the setup wizard can
|
||||||
|
// poll for download progress.
|
||||||
|
let pullProcess = null
|
||||||
|
let pullProgress = { status: "idle", progress: 0, message: "" }
|
||||||
|
|
||||||
// ── Job loading ─────────────────────────────────────────────────
|
// ── Job loading ─────────────────────────────────────────────────
|
||||||
|
// Loads job categories from a JSONL file (one JSON object per line).
|
||||||
|
// Used as context for the AI sales coach chat responses.
|
||||||
function loadJobs() {
|
function loadJobs() {
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
const content = fs.readFileSync(JOBS_PATH, "utf-8")
|
||||||
@@ -59,6 +83,8 @@ function loadJobs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── ai.md management ────────────────────────────────────────────
|
// ── ai.md management ────────────────────────────────────────────
|
||||||
|
// ai.md is a Markdown file containing system instructions for the AI.
|
||||||
|
// It can be read, written, or appended to via the API.
|
||||||
function readInstructions() {
|
function readInstructions() {
|
||||||
try {
|
try {
|
||||||
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
return fs.readFileSync(AI_MD_PATH, "utf-8")
|
||||||
@@ -73,6 +99,7 @@ function writeInstructions(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function appendToImprovementLog(entry) {
|
function appendToImprovementLog(entry) {
|
||||||
|
// Adds a timestamped entry to the ## Improvement Log section of ai.md
|
||||||
const current = readInstructions()
|
const current = readInstructions()
|
||||||
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
const timestamp = new Date().toISOString().replace("T", " ").substring(0, 16)
|
||||||
const logEntry = `\n- ${timestamp} — ${entry}`
|
const logEntry = `\n- ${timestamp} — ${entry}`
|
||||||
@@ -96,13 +123,17 @@ function appendToImprovementLog(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Chat handler ────────────────────────────────────────────────
|
// ── Chat handler ────────────────────────────────────────────────
|
||||||
|
// scrapeFacebook() calls the scraper service (port 3008) to get leads.
|
||||||
|
// handleChat() processes user messages — triggers lead scraping when
|
||||||
|
// the user asks for "leads" or "listings", otherwise routes to Ollama
|
||||||
|
// for AI-powered sales coaching.
|
||||||
async function scrapeFacebook() {
|
async function scrapeFacebook() {
|
||||||
const profilePath = process.env.FX_PROFILE || ""
|
const profilePath = process.env.FX_PROFILE || ""
|
||||||
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
|
||||||
const logPath = "C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-scrape-debug.log"
|
|
||||||
try {
|
try {
|
||||||
const body = await new Promise((resolve, reject) => {
|
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)
|
||||||
|
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 360000 }, (res) => {
|
||||||
let data = ""
|
let data = ""
|
||||||
res.on("data", (c) => data += c)
|
res.on("data", (c) => data += c)
|
||||||
res.on("end", () => resolve(data))
|
res.on("end", () => resolve(data))
|
||||||
@@ -134,6 +165,7 @@ function formatLeads(leads) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleChat(userMessage, userId, userRole) {
|
async function handleChat(userMessage, userId, userRole) {
|
||||||
|
// If the user asks for leads, trigger the scraper
|
||||||
const lowerMsg = userMessage.toLowerCase()
|
const lowerMsg = userMessage.toLowerCase()
|
||||||
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
const triggerWords = ["lists", "listings", "leads", "recent leads", "pull leads", "show me leads", "show listings"]
|
||||||
|
|
||||||
@@ -145,6 +177,7 @@ async function handleChat(userMessage, userId, userRole) {
|
|||||||
return "Scraper returned no results or encountered an error. Try again later."
|
return "Scraper returned no results or encountered an error. Try again later."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Otherwise, build a system prompt with job context and send to Ollama
|
||||||
const jobs = loadedJobs
|
const jobs = loadedJobs
|
||||||
const instructions = readInstructions()
|
const instructions = readInstructions()
|
||||||
|
|
||||||
@@ -184,7 +217,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
const data = await ollamaRes.json()
|
const data = await ollamaRes.json()
|
||||||
const responseText = data.message?.content || ""
|
const responseText = data.message?.content || ""
|
||||||
|
|
||||||
// Try to persist to DB (best-effort)
|
// Persist conversation to PostgreSQL (best-effort — table may not exist yet)
|
||||||
try {
|
try {
|
||||||
if (pgPool && userId) {
|
if (pgPool && userId) {
|
||||||
await pgPool.query(
|
await pgPool.query(
|
||||||
@@ -200,6 +233,8 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── PG pool (lazy init) ────────────────────────────────────────
|
// ── PG pool (lazy init) ────────────────────────────────────────
|
||||||
|
// PostgreSQL connection pool for storing conversation history.
|
||||||
|
// Lazy-initialized so the server starts even without a DB.
|
||||||
let pgPool = null
|
let pgPool = null
|
||||||
async function initPg() {
|
async function initPg() {
|
||||||
if (!DATABASE_URL) return
|
if (!DATABASE_URL) return
|
||||||
@@ -241,8 +276,9 @@ function parseURL(req) {
|
|||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HTTP Server ─────────────────────────────────────────────────
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
// CORS
|
// CORS headers — allow the Next.js frontend (port 3006) to call us
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*")
|
res.setHeader("Access-Control-Allow-Origin", "*")
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type")
|
||||||
@@ -275,13 +311,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /status — combined health of all services
|
// GET /status — combined health of all services
|
||||||
|
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
||||||
|
// Polls each service internally to avoid cross-origin CORS issues.
|
||||||
if (req.method === "GET" && pathname === "/status") {
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
const { default: http } = await import("http")
|
const { default: http } = await import("http")
|
||||||
const results = { ai: true }
|
const results = { ai: true }
|
||||||
// Check scraper
|
// Check scraper
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
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("error", reject)
|
r.on("error", reject)
|
||||||
})
|
})
|
||||||
results.scraper = true
|
results.scraper = true
|
||||||
@@ -289,7 +327,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
// Check frontend
|
// Check frontend
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
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("error", reject)
|
r.on("error", reject)
|
||||||
})
|
})
|
||||||
results.frontend = true
|
results.frontend = true
|
||||||
@@ -297,18 +335,193 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendJSON(res, 200, results)
|
return sendJSON(res, 200, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /ai/jobs
|
// ── Setup endpoints ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// GET /setup/status — check environment
|
||||||
|
// Called by the splash page on boot. Returns info about:
|
||||||
|
// - Ollama availability
|
||||||
|
// - Model presence
|
||||||
|
// - Detected browsers with login status
|
||||||
|
// - Whether this is a first run (wizard needed)
|
||||||
|
if (req.method === "GET" && pathname === "/setup/status") {
|
||||||
|
const envExists = fs.existsSync(path.join(ROOT, ".env.local"))
|
||||||
|
|
||||||
|
// Ollama check
|
||||||
|
let ollamaRunning = false
|
||||||
|
try {
|
||||||
|
await fetch(`${OLLAMA_URL}/api/tags`, { signal: AbortSignal.timeout(3000) })
|
||||||
|
ollamaRunning = true
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Model check
|
||||||
|
let modelAvailable = false
|
||||||
|
if (ollamaRunning) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${OLLAMA_URL}/api/show`, {
|
||||||
|
method: "POST", body: JSON.stringify({ name: MODEL }),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
})
|
||||||
|
modelAvailable = r.ok
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect all browsers via scraper
|
||||||
|
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||||
|
let facebookLoggedIn = false
|
||||||
|
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
// Check login for the selected browser first, then try all
|
||||||
|
const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
|
||||||
|
for (const [b, v] of detectedList) {
|
||||||
|
try {
|
||||||
|
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),
|
||||||
|
})
|
||||||
|
if (r.ok) {
|
||||||
|
const d = await r.json()
|
||||||
|
browsers[b].logged_in = d.logged_in === true
|
||||||
|
if (d.logged_in && !facebookLoggedIn) {
|
||||||
|
facebookLoggedIn = true
|
||||||
|
if (!selectedBrowser) selectedBrowser = b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||||
|
// first_run = any setup step is incomplete
|
||||||
|
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
||||||
|
|
||||||
|
return sendJSON(res, 200, {
|
||||||
|
first_run: firstRun,
|
||||||
|
env_exists: envExists,
|
||||||
|
ollama_running: ollamaRunning,
|
||||||
|
model_available: modelAvailable,
|
||||||
|
model_name: MODEL,
|
||||||
|
selected_browser: selectedBrowser,
|
||||||
|
browsers,
|
||||||
|
facebook_logged_in: facebookLoggedIn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/profile — save selected browser + path to .env.local
|
||||||
|
// Called by the setup wizard when the user confirms their browser choice.
|
||||||
|
// Writes SELECTED_BROWSER and the matching profile env var to .env.local.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/profile") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const browserName = (body.browser || "").trim().toLowerCase()
|
||||||
|
const profilePath = (body.path || "").trim()
|
||||||
|
if (!browserName || !["firefox", "opera", "chrome", "edge"].includes(browserName))
|
||||||
|
return sendJSON(res, 400, { error: "Valid browser required (firefox/opera/chrome/edge)" })
|
||||||
|
if (!profilePath)
|
||||||
|
return sendJSON(res, 400, { error: "Path required" })
|
||||||
|
|
||||||
|
const envKey = browserName === "firefox" ? "FX_PROFILE"
|
||||||
|
: browserName === "opera" ? "OPERA_PROFILE"
|
||||||
|
: browserName === "edge" ? "EDGE_PROFILE"
|
||||||
|
: "CHROME_PROFILE"
|
||||||
|
|
||||||
|
const envPath = path.join(ROOT, ".env.local")
|
||||||
|
let content = ""
|
||||||
|
try { content = fs.readFileSync(envPath, "utf-8") } catch {}
|
||||||
|
let lines = content.split("\n")
|
||||||
|
// Update or add SELECTED_BROWSER
|
||||||
|
let foundSel = false
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim().startsWith("SELECTED_BROWSER=")) {
|
||||||
|
lines[i] = `SELECTED_BROWSER=${browserName}`
|
||||||
|
foundSel = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundSel) lines.push(`SELECTED_BROWSER=${browserName}`)
|
||||||
|
// Update or add browser profile
|
||||||
|
let foundProf = false
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].trim().startsWith(`${envKey}=`)) {
|
||||||
|
lines[i] = `${envKey}=${profilePath}`
|
||||||
|
foundProf = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundProf) lines.push(`${envKey}=${profilePath}`)
|
||||||
|
fs.writeFileSync(envPath, lines.join("\n"), "utf-8")
|
||||||
|
process.env.SELECTED_BROWSER = browserName
|
||||||
|
process.env[envKey] = profilePath
|
||||||
|
return sendJSON(res, 200, { success: true, browser: browserName, path: profilePath })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/check-login — proxy to scraper, accepts browser + profile_path
|
||||||
|
// The splash page calls this (via the AI server) to verify Facebook login status.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/check-login") {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const browserName = (body.browser || "").trim().toLowerCase() || process.env.SELECTED_BROWSER || ""
|
||||||
|
const profilePath = (body.profile_path || "").trim()
|
||||||
|
|
||||||
|
if (!profilePath) return sendJSON(res, 200, { logged_in: false, reason: "no_profile" })
|
||||||
|
try {
|
||||||
|
const r = await fetch("http://127.0.0.1:3008/setup/check-login", {
|
||||||
|
method: "POST", headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ browser: browserName, profile_path: profilePath }),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
})
|
||||||
|
if (r.ok) { const d = await r.json(); return sendJSON(res, 200, d) }
|
||||||
|
} catch {}
|
||||||
|
return sendJSON(res, 200, { logged_in: false, reason: "scraper_unavailable" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /setup/ollama/pull — start pulling the model
|
||||||
|
// Spawns "ollama pull" as a child process. The setup wizard polls
|
||||||
|
// the progress endpoint to show a download progress bar.
|
||||||
|
if (req.method === "POST" && pathname === "/setup/ollama/pull") {
|
||||||
|
if (pullProcess) return sendJSON(res, 200, { status: "already_running" })
|
||||||
|
pullProgress = { status: "downloading", progress: 0, message: "Starting..." }
|
||||||
|
const isWin = process.platform === "win32"
|
||||||
|
const cmd = isWin ? "ollama.exe" : "ollama"
|
||||||
|
pullProcess = spawn(cmd, ["pull", MODEL], { stdio: ["ignore", "pipe", "pipe"] })
|
||||||
|
|
||||||
|
pullProcess.stdout.on("data", (data) => {
|
||||||
|
const text = data.toString()
|
||||||
|
pullProgress.message = text.trim()
|
||||||
|
// Extract percentage from patterns like "pulling xxxx... 45%"
|
||||||
|
const m = text.match(/(\d+)%/)
|
||||||
|
if (m) pullProgress.progress = parseInt(m[1], 10)
|
||||||
|
})
|
||||||
|
pullProcess.on("close", (code) => {
|
||||||
|
pullProcess = null
|
||||||
|
pullProgress.status = code === 0 ? "done" : "failed"
|
||||||
|
if (code === 0) pullProgress.progress = 100
|
||||||
|
})
|
||||||
|
return sendJSON(res, 200, { status: "started" })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /setup/ollama/pull/progress
|
||||||
|
// Returns current download progress for the setup wizard.
|
||||||
|
if (req.method === "GET" && pathname === "/setup/ollama/pull/progress") {
|
||||||
|
return sendJSON(res, 200, pullProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /ai/jobs — return loaded job categories
|
||||||
if (req.method === "GET" && pathname === "/ai/jobs") {
|
if (req.method === "GET" && pathname === "/ai/jobs") {
|
||||||
return sendJSON(res, 200, { jobs: loadedJobs })
|
return sendJSON(res, 200, { jobs: loadedJobs })
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /ai/instructions
|
// GET /ai/instructions — return current ai.md content
|
||||||
if (req.method === "GET" && pathname === "/ai/instructions") {
|
if (req.method === "GET" && pathname === "/ai/instructions") {
|
||||||
const instructions = readInstructions()
|
const instructions = readInstructions()
|
||||||
return sendJSON(res, 200, { success: true, instructions })
|
return sendJSON(res, 200, { success: true, instructions })
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /ai/instructions
|
// POST /ai/instructions — update ai.md or append improvement log entry
|
||||||
if (req.method === "POST" && pathname === "/ai/instructions") {
|
if (req.method === "POST" && pathname === "/ai/instructions") {
|
||||||
const body = await parseBody(req)
|
const body = await parseBody(req)
|
||||||
if (body.content) {
|
if (body.content) {
|
||||||
@@ -322,18 +535,17 @@ const server = http.createServer(async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /ai/chat
|
// POST /ai/chat — main AI chat endpoint
|
||||||
|
// 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") {
|
if (req.method === "POST" && pathname === "/ai/chat") {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const chunks = []
|
const chunks = []
|
||||||
req.on("data", c => chunks.push(c))
|
req.on("data", c => chunks.push(c))
|
||||||
req.on("end", () => {
|
req.on("end", () => {
|
||||||
const rawBody = Buffer.concat(chunks).toString()
|
const rawBody = Buffer.concat(chunks).toString()
|
||||||
try { fs.appendFileSync("C:\\Users\\USER-PC\\AppData\\Local\\Temp\\opencode\\ai-req-log.txt",
|
|
||||||
`${new Date().toISOString()} headers=${JSON.stringify(req.headers)} body=${rawBody}\n`) } catch {}
|
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(rawBody)
|
const body = JSON.parse(rawBody)
|
||||||
// Continue processing
|
|
||||||
processRequest(req, res, body, startTime)
|
processRequest(req, res, body, startTime)
|
||||||
} catch {
|
} catch {
|
||||||
sendJSON(res, 400, { error: "Invalid JSON" })
|
sendJSON(res, 400, { error: "Invalid JSON" })
|
||||||
@@ -342,7 +554,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separate handler
|
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
|
||||||
async function processRequest(req, res, body, startTime) {
|
async function processRequest(req, res, body, startTime) {
|
||||||
const { message, user_id, user_role } = body
|
const { message, user_id, user_role } = body
|
||||||
|
|
||||||
@@ -359,7 +571,7 @@ async function processRequest(req, res, body, startTime) {
|
|||||||
return sendJSON(res, 200, { response })
|
return sendJSON(res, 200, { response })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 404
|
// 404 fallback
|
||||||
sendJSON(res, 404, { error: "Not found" })
|
sendJSON(res, 404, { error: "Not found" })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Request error:", err)
|
console.error("Request error:", err)
|
||||||
|
|||||||
+1263
-165
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
# ── Python Dependencies for the Facebook Scraper (FastAPI) ───────
|
||||||
|
# Install via: pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Web framework for the REST API (health, setup, scrape endpoints)
|
||||||
|
fastapi>=0.115.0
|
||||||
|
|
||||||
|
# ASGI server for running the FastAPI app
|
||||||
|
uvicorn>=0.34.0
|
||||||
|
|
||||||
|
# Browser automation (launches Firefox, Chrome, Edge, Opera via Playwright)
|
||||||
|
playwright>=1.49.0
|
||||||
|
|
||||||
|
# AI-powered browser agent (fallback when direct browser scraping is flagged)
|
||||||
|
# Uses ChatOllama locally — no API keys needed
|
||||||
|
browser-use>=0.1.0
|
||||||
|
|
||||||
|
# LangChain integration for ChatOllama (provides the LLM for browser-use Agent)
|
||||||
|
langchain-ollama>=0.2.0
|
||||||
+126
-26
@@ -1,40 +1,140 @@
|
|||||||
# AI Sales Assistant — Self-Improvement Instructions
|
# CRM AI Sales Assistant — Self-Knowledge
|
||||||
|
|
||||||
## Purpose
|
## Identity
|
||||||
This file contains the AI's own configuration, knowledge, and improvement rules.
|
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||||
The AI can read and modify this file to update its behavior at runtime.
|
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
|
## Architecture
|
||||||
- Always respond in English
|
```
|
||||||
- Keep responses under 300 words unless asked for detail
|
User → Next.js (:3006) → AI Server Node.js (:3001) → Ollama (:11434)
|
||||||
- Use bullet points for lists
|
↓
|
||||||
- Be direct and actionable — no fluff
|
PostgreSQL (conversations)
|
||||||
- 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
|
|
||||||
|
|
||||||
## Knowledge Base
|
Python Scraper (:3008) — Facebook scraping via Playwright
|
||||||
### Sales Tips
|
```
|
||||||
|
|
||||||
|
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. **Search dispatch** — per scrape run:
|
||||||
|
- 1 English primary search (full scroll with human-like delays)
|
||||||
|
- 2-3 English supplementary searches (quick searches)
|
||||||
|
- 6-7 non-English quick searches (Afrikaans, isiXhosa, isiZulu — 2 queries each per category)
|
||||||
|
- Total: ~14 searches per scrape, completed in 2-4 minutes
|
||||||
|
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each)
|
||||||
|
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 Support
|
||||||
|
Searches in 4 South African languages:
|
||||||
|
- English — 1 primary + 2-3 supplementary queries
|
||||||
|
- Afrikaans — 2 queries (e.g., "ek benodig n webwerf", "ek soek n privaat onderwyser")
|
||||||
|
- isiXhosa — 2 queries (e.g., "ndidinga iwebhusayithi yeshishini", "ndifuna utitshala womntwana wam")
|
||||||
|
- isiZulu — 2 queries (e.g., "ngidinga iwebhusayithi yebhizinisi", "ngifuna umfundisi wengane")
|
||||||
|
|
||||||
|
### 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
|
- Cold emails should be under 150 words
|
||||||
- Follow up within 48 hours
|
- Follow up within 48 hours
|
||||||
- Personalise every outreach with the prospect's name and company
|
- Personalise every outreach with the prospect's name and company
|
||||||
- Use open-ended questions in discovery calls
|
- Use open-ended questions in discovery calls
|
||||||
- Always ask for the next step before ending a call
|
- 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
|
- Developers respond best to technical value props
|
||||||
- Marketing managers care about ROI and metrics
|
- Marketing managers care about ROI and metrics
|
||||||
- C-level executives want brevity and business impact
|
- 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
|
## Improvement Log
|
||||||
Track changes made by the AI to improve itself:
|
- (2026-07-07) Initial rewrite: full architecture, scraper details, multi-language, lead categories, env vars
|
||||||
- (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.
|
|
||||||
|
|||||||
+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":"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":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
{"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"}
|
||||||
{"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"}
|
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- admin_demo / AdminAccess@2026
|
-- admin_demo / AdminAccess@2026
|
||||||
-- sales_demo / SalesAccess@2026
|
-- sales_demo / SalesAccess@2026
|
||||||
-- dev_demo / DevTesting@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
|
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',
|
('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),
|
'Sales', 'User', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
'$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;
|
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||||
|
|
||||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
-- 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)
|
-- Set created_by for other users (SUPER_ADMIN created them)
|
||||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
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;
|
AND created_by IS NULL;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
|
|||||||
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
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-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-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;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS scheduled_events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
participant_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
conversation_id UUID REFERENCES conversations(id) ON DELETE SET NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
event_type VARCHAR(20) NOT NULL DEFAULT 'meeting',
|
||||||
|
start_time TIMESTAMPTZ NOT NULL,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_minutes INT,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'scheduled',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_event_type CHECK (event_type IN ('meeting','call','chat','follow_up','demo','other')),
|
||||||
|
CONSTRAINT chk_event_status CHECK (status IN ('scheduled','completed','cancelled','rescheduled'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_user ON scheduled_events(user_id, start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_participant ON scheduled_events(participant_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_lead ON scheduled_events(lead_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_date ON scheduled_events(start_time);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_scheduled_events_status ON scheduled_events(user_id, status);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS sent_emails (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(255) NOT NULL,
|
||||||
|
body_html TEXT,
|
||||||
|
body_text TEXT,
|
||||||
|
event_id UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sent_emails_recipient ON sent_emails(recipient);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sent_emails_created ON sent_emails(created_at DESC);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 013: Row-Level Security for scheduled_events
|
||||||
|
-- ============================================================================
|
||||||
|
-- Enables RLS on the scheduled_events table so that users can only see
|
||||||
|
-- their own events at the database level. This is defense-in-depth:
|
||||||
|
-- the API already filters by user_id, but RLS ensures data isolation
|
||||||
|
-- even if there's a bug in the application layer.
|
||||||
|
--
|
||||||
|
-- The policy allows all operations when app.current_user_id is NULL
|
||||||
|
-- (backward-compatible fallback for queries that don't set the variable).
|
||||||
|
-- When the variable IS set, only rows matching the user's ID are visible.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE scheduled_events ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
|
||||||
|
CREATE POLICY scheduled_events_user_policy ON scheduled_events
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
user_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR current_setting('app.current_user_id', true) IS NULL
|
||||||
|
);
|
||||||
@@ -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')
|
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
||||||
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
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:
|
-- FUTURE REQUIREMENT NOTE:
|
||||||
-- If this system is ever exposed publicly, remove reversible password storage
|
-- If this system is ever exposed publicly, remove reversible password storage
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE notifications
|
||||||
|
ADD COLUMN IF NOT EXISTS context_id UUID,
|
||||||
|
ADD COLUMN IF NOT EXISTS context_type VARCHAR(50);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_context ON notifications(context_type, context_id);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 015: Fix RLS for scheduled_events to include participant_id
|
||||||
|
-- ============================================================================
|
||||||
|
-- Previously, the RLS policy only checked user_id, which meant participants
|
||||||
|
-- could not see events at the database level (the app-layer WHERE clause
|
||||||
|
-- did the filtering, but RLS was defense-in-depth that missed this case).
|
||||||
|
--
|
||||||
|
-- This policy also allows app.current_user_id IS NULL for backward compat
|
||||||
|
-- with queries that don't set the session variable.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS scheduled_events_user_policy ON scheduled_events;
|
||||||
|
CREATE POLICY scheduled_events_user_policy ON scheduled_events
|
||||||
|
FOR ALL
|
||||||
|
USING (
|
||||||
|
user_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR participant_id = current_setting('app.current_user_id', true)::uuid
|
||||||
|
OR current_setting('app.current_user_id', true) IS NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE scheduled_events ADD COLUMN IF NOT EXISTS participant_notes TEXT;
|
||||||
@@ -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;
|
||||||
@@ -34,11 +34,53 @@ BEGIN;
|
|||||||
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
\echo '=== Running 009_settings.sql (Company Settings + User Preferences) ==='
|
||||||
\i 009_settings.sql
|
\i 009_settings.sql
|
||||||
|
|
||||||
\echo '=== Running 013_security_upgrade.sql (Security Architecture: RLS, Encryption, Master Keys, Backups) ==='
|
\echo '=== Running 010_chat_notifications.sql (Chat Notifications) ==='
|
||||||
|
\i 010_chat_notifications.sql
|
||||||
|
|
||||||
|
\echo '=== Running 010_facebook_accounts.sql (Facebook Accounts) ==='
|
||||||
|
\i 010_facebook_accounts.sql
|
||||||
|
|
||||||
|
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
||||||
|
\i 011_calendar_events.sql
|
||||||
|
|
||||||
|
\echo '=== Running 012_invites.sql (Invites) ==='
|
||||||
|
\i 012_invites.sql
|
||||||
|
|
||||||
|
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
||||||
|
\i 012_sent_emails.sql
|
||||||
|
|
||||||
|
\echo '=== Running 013_security_upgrade.sql (Security Architecture) ==='
|
||||||
\i 013_security_upgrade.sql
|
\i 013_security_upgrade.sql
|
||||||
|
|
||||||
|
\echo '=== Running 013_rls_calendar.sql (Calendar RLS) ==='
|
||||||
|
\i 013_rls_calendar.sql
|
||||||
|
|
||||||
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
\echo '=== Running 014_bug_reports.sql (Bug Reporting System) ==='
|
||||||
\i 014_bug_reports.sql
|
\i 014_bug_reports.sql
|
||||||
|
|
||||||
|
\echo '=== Running 014_notifications_context.sql (Notifications Context) ==='
|
||||||
|
\i 014_notifications_context.sql
|
||||||
|
|
||||||
|
\echo '=== Running 015_rls_calendar_participant.sql (Calendar RLS Participant Fix) ==='
|
||||||
|
\i 015_rls_calendar_participant.sql
|
||||||
|
|
||||||
|
\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 ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from "eslint-config-next/core-web-vitals.js";
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from "eslint-config-next/typescript.js";
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
|
|||||||
+16
-9
@@ -1,12 +1,4 @@
|
|||||||
import type { NextConfig } from "next"
|
import type { NextConfig } from "next"
|
||||||
import crypto from "crypto"
|
|
||||||
|
|
||||||
// In development, generate a random JWT secret on every server start.
|
|
||||||
// This invalidates all previously issued JWT tokens, ensuring the user
|
|
||||||
// must re-authenticate after each dev server restart.
|
|
||||||
if (process.env.NODE_ENV === "development") {
|
|
||||||
process.env.JWT_SECRET = crypto.randomBytes(32).toString("hex")
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
@@ -20,7 +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
|
export default nextConfig
|
||||||
|
|||||||
Generated
+2888
-165
File diff suppressed because it is too large
Load Diff
+26
-10
@@ -5,21 +5,30 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:signaling": "node signaling-server.mjs",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
"dev:open": "powershell -NoProfile -Command \"Start-Sleep 8; Start-Process 'http://localhost:3001/splash'\"",
|
"dev:open": "node scripts/open-browser.mjs",
|
||||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"dev:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "powershell -NoProfile -Command \"Get-NetTCPConnection -State Listen | Where-Object { $_.LocalPort -in 3001,3006,3007,3008 } | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue; Write-Host ('Freed port '+$_.LocalPort) }\"",
|
"dev:precheck": "node scripts/precheck.mjs",
|
||||||
"dev:ollama": "powershell -NoProfile -Command \"$ollama = if (Get-Command ollama -ErrorAction SilentlyContinue) { 'ollama' } else { Join-Path $env:LOCALAPPDATA 'Programs\\Ollama\\ollama.exe' }; if (-not (Get-Process ollama -ErrorAction SilentlyContinue)) { Start-Process $ollama -ArgumentList 'serve' -WindowStyle Hidden; Start-Sleep 3 }; exit 0\"",
|
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"dev:rust": "node ai-server/index.mjs",
|
"dev:rust": "node ai-server/index.mjs",
|
||||||
"dev:browser-use": "set FX_PROFILE=C:\\Users\\USER-PC\\AppData\\Roaming\\Mozilla\\Firefox\\Profiles\\h8p11vlj.default-release && cd browser-use-service && python main.py",
|
"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",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"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": {
|
"dependencies": {
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.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-alert-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
@@ -41,11 +50,13 @@
|
|||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.0.4",
|
"next": "15.0.4",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"nodemailer": "^9.0.1",
|
||||||
"pg": "^8.21.0",
|
"pg": "^8.21.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -59,15 +70,20 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@next/swc-win32-x64-msvc": "^15.0.4",
|
||||||
|
"@types/node": "20.19.43",
|
||||||
|
"@types/nodemailer": "8.0.1",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "18.3.31",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "18.3.7",
|
||||||
"concurrently": "^10.0.3",
|
"concurrently": "^10.0.3",
|
||||||
|
"devenv": "1.0.1",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.0.4",
|
"eslint-config-next": "15.0.4",
|
||||||
|
"maildev": "^2.2.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^1.6.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+120
-23
@@ -1,9 +1,23 @@
|
|||||||
# CRM AI Service — Self-Knowledge
|
# CRM AI Sales Assistant — Self-Knowledge
|
||||||
|
|
||||||
## Identity
|
## Identity
|
||||||
You are the CRM AI Sales Assistant running on a Rust backend (axum + tokio).
|
You are the CRM AI Sales Assistant for Coast IT CRM.
|
||||||
You use Ollama with an uncensored local model (dolphin3-llama3.2:3b).
|
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.
|
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
|
## Capabilities
|
||||||
- Give sales tips and strategies per job category
|
- Give sales tips and strategies per job category
|
||||||
@@ -11,29 +25,101 @@ Your purpose is to help salespeople close more deals.
|
|||||||
- Handle objections with proven rebuttals
|
- Handle objections with proven rebuttals
|
||||||
- Analyse prospect behaviour and suggest next steps
|
- Analyse prospect behaviour and suggest next steps
|
||||||
- Remember past conversations via PostgreSQL (`ai_conversations` table)
|
- 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`
|
- Self-improve by writing to `data/ai/ai.md` via `POST /ai/instructions`
|
||||||
|
|
||||||
## Architecture
|
## Facebook Scraper
|
||||||
```
|
The scraper lives at `browser-use-service/main.py` port 3008.
|
||||||
User → Next.js → Rust (:3001) → Ollama (:11434)
|
|
||||||
↓
|
|
||||||
PostgreSQL
|
|
||||||
```
|
|
||||||
## Facebook Scraper The Rust binary has proxy + user-agent rotation ready in `main.rs`. To activate: call `run_facebook_scraper()` from the main loop. Proxies and user agents are defined as constants at the top of `main.rs`.
|
|
||||||
### How to Use the Facebook Scraper 1. **Activate the Scraper**: Ensure the `run_facebook_scraper()` function is called from the main loop in `main.rs`. 2. **Configure Proxies and User Agents**: Update the `PROXIES` and `USER_AGENTS` constants in `main.rs` with your desired proxies and user agents. 3. **Run the Application**: Start your Rust application. The scraper will run concurrently with the Axum server. ## Self-Improvement Protocol 1. You notice a gap in your knowledge or a pattern in user questions 2. You call `POST /ai/instructions` with: - `entry`: description of the improvement - `content`: optional full replacement of ai.md 3. The improvement is logged and loaded into the next system prompt 4. You can also create entries here in `rust-ai/src/AI.md` at the code level ## Response Rules - Be direct and actionable — no fluff, no AI disclaimers - Use short paragraphs and bullet points - Never mention being an AI or language model - If you don't know something, say so honestly - Prioritise the user's role: salespeople need speed, admins need control ## Self-Thinking Capability - You can reflect on your own performance and suggest improvements. - You can analyze patterns in user interactions and adapt your responses accordingly. - You can proactively seek out new information to enhance your knowledge base.
|
|
||||||
|
|
||||||
## Facebook Scraper (in code but not yet active)
|
### How It Works
|
||||||
The Rust binary has proxy + user-agent rotation ready in `main.rs`.
|
1. **Browser detection** — tries Firefox profile first, then Chromium-based (Chrome/Opera/Edge), falls back to browser-use Agent
|
||||||
To activate: call `run_facebook_scraper()` from the main loop.
|
2. **Profile paths** — configured via env vars (`FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE`) or auto-detected on first run
|
||||||
Proxies and user agents are defined as constants at the top of `main.rs`.
|
3. **Search dispatch** — per scrape run:
|
||||||
|
- 1 English primary search (full scroll with human-like delays)
|
||||||
|
- 2-3 English supplementary searches (quick searches)
|
||||||
|
- 6-7 non-English quick searches (Afrikaans, isiXhosa, isiZulu — 2 queries each per category)
|
||||||
|
- Total: ~14 searches per scrape, completed in 2-4 minutes
|
||||||
|
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each)
|
||||||
|
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
|
### Lead Categories
|
||||||
1. You notice a gap in your knowledge or a pattern in user questions
|
Two categories, selectable when starting a scrape:
|
||||||
2. You call `POST /ai/instructions` with:
|
|
||||||
- `entry`: description of the improvement
|
**Website Creation:**
|
||||||
- `content`: optional full replacement of ai.md
|
- Target: people explicitly REQUESTING a website built/designed/created for them
|
||||||
3. The improvement is logged and loaded into the next system prompt
|
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
|
||||||
4. You can also create entries here in `rust-ai/src/AI.md` at the code level
|
- 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 Support
|
||||||
|
Searches in 4 South African languages:
|
||||||
|
- English — 1 primary + 2-3 supplementary queries
|
||||||
|
- Afrikaans — 2 queries (e.g., "ek benodig n webwerf", "ek soek n privaat onderwyser")
|
||||||
|
- isiXhosa — 2 queries (e.g., "ndidinga iwebhusayithi yeshishini", "ndifuna utitshala womntwana wam")
|
||||||
|
- isiZulu — 2 queries (e.g., "ngidinga iwebhusayithi yebhizinisi", "ngifuna umfundisi wengane")
|
||||||
|
|
||||||
|
### 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
|
## Response Rules
|
||||||
- Be direct and actionable — no fluff, no AI disclaimers
|
- Be direct and actionable — no fluff, no AI disclaimers
|
||||||
@@ -41,3 +127,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
|
|||||||
- Never mention being an AI or language model
|
- Never mention being an AI or language model
|
||||||
- If you don't know something, say so honestly
|
- If you don't know something, say so honestly
|
||||||
- Prioritise the user's role: salespeople need speed, admins need control
|
- 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
|
||||||
|
|||||||
+8
-7
@@ -1,6 +1,6 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::{HeaderMap, Method, StatusCode},
|
http::{HeaderMap, HeaderValue, Method, StatusCode},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
@@ -446,7 +446,7 @@ async fn main() {
|
|||||||
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
let jwt_secret = std::env::var("JWT_SECRET").expect("JWT_SECRET must be set");
|
||||||
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
let ollama_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "llama3.2:3b".to_string());
|
let model = std::env::var("AI_MODEL").unwrap_or_else(|_| "dolphin-phi".to_string());
|
||||||
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
let host = std::env::var("AI_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
let port: u16 = std::env::var("AI_PORT").unwrap_or_else(|_| "3001".to_string()).parse().expect("AI_PORT must be a number");
|
||||||
|
|
||||||
@@ -482,11 +482,12 @@ async fn main() {
|
|||||||
rate_limiter: RateLimiter::new(30, 60),
|
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()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(AllowOrigin::list([
|
.allow_origin(AllowOrigin::list(cors_origins))
|
||||||
"http://localhost:3006".parse().unwrap(),
|
|
||||||
"http://127.0.0.1:3006".parse().unwrap(),
|
|
||||||
]))
|
|
||||||
.allow_methods([Method::GET, Method::POST])
|
.allow_methods([Method::GET, Method::POST])
|
||||||
.allow_headers(Any);
|
.allow_headers(Any);
|
||||||
|
|
||||||
@@ -506,7 +507,7 @@ async fn main() {
|
|||||||
|
|
||||||
let bg_leads = lead_store.clone();
|
let bg_leads = lead_store.clone();
|
||||||
let bg_db = state.db.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 {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
.timeout(Duration::from_secs(300))
|
.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,56 @@
|
|||||||
|
// ── Ollama Launcher ────────────────────────────────────────────────
|
||||||
|
// Checks if Ollama is already running. If not, finds the binary and
|
||||||
|
// starts it as a detached background process.
|
||||||
|
// Cross-platform: uses tasklist (Windows) or pgrep (Linux/Mac) to
|
||||||
|
// detect running process; uses where/which to find the binary.
|
||||||
|
|
||||||
|
import { execSync, spawn } from "node:child_process"
|
||||||
|
import { platform } from "node:os"
|
||||||
|
|
||||||
|
function isRunning() {
|
||||||
|
try {
|
||||||
|
if (platform() === "win32") {
|
||||||
|
execSync('tasklist /FI "IMAGENAME eq ollama.exe" 2>nul | findstr ollama', { stdio: "pipe", timeout: 3000 })
|
||||||
|
} else {
|
||||||
|
execSync('pgrep -x ollama', { stdio: "pipe", timeout: 3000 })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOllama() {
|
||||||
|
if (platform() === "win32") {
|
||||||
|
// Try PATH first, then common install locations
|
||||||
|
try {
|
||||||
|
return execSync("where ollama", { encoding: "utf8", timeout: 3000 }).trim().split("\n")[0]
|
||||||
|
} catch {}
|
||||||
|
const local = `${process.env.LOCALAPPDATA}\\Programs\\Ollama\\ollama.exe`
|
||||||
|
const programFiles = `${process.env.PROGRAMFILES}\\Ollama\\ollama.exe`
|
||||||
|
if (local) try { execSync(`"${local}" --version`, { stdio: "ignore", timeout: 2000 }); return local } catch {}
|
||||||
|
if (programFiles) try { execSync(`"${programFiles}" --version`, { stdio: "ignore", timeout: 2000 }); return programFiles } catch {}
|
||||||
|
} else {
|
||||||
|
try { return execSync("which ollama", { encoding: "utf8", timeout: 3000 }).trim() } catch {}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRunning()) {
|
||||||
|
const bin = findOllama()
|
||||||
|
if (!bin) {
|
||||||
|
console.error("Ollama not found. Install from https://ollama.com")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log("Starting Ollama...")
|
||||||
|
// Spawn detached so it outlives this script and the npm process
|
||||||
|
if (platform() === "win32") {
|
||||||
|
spawn(bin, ["serve"], { stdio: "ignore", detached: true, windowsHide: true }).unref()
|
||||||
|
} else {
|
||||||
|
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
|
||||||
|
}
|
||||||
|
// Give it a moment to start listening
|
||||||
|
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
||||||
|
} else {
|
||||||
|
console.log("Ollama already running")
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ── Browser Opener ─────────────────────────────────────────────────
|
||||||
|
// Opens the splash page in the user's default browser after an 8-second
|
||||||
|
// delay. The delay gives all services time to start before the user
|
||||||
|
// sees the loading screen.
|
||||||
|
// Cross-platform: uses start (Windows), open (Mac), or xdg-open (Linux).
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { platform } from "node:os"
|
||||||
|
|
||||||
|
const url = process.argv[2] || "http://localhost:3001/splash"
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await sleep(8000)
|
||||||
|
try {
|
||||||
|
if (platform() === "win32") {
|
||||||
|
execSync(`start "" "${url}"`, { stdio: "ignore", timeout: 5000 })
|
||||||
|
} else if (platform() === "darwin") {
|
||||||
|
execSync(`open "${url}"`, { stdio: "ignore", timeout: 5000 })
|
||||||
|
} else {
|
||||||
|
execSync(`xdg-open "${url}"`, { stdio: "ignore", timeout: 5000 })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to open browser:", e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// ── 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.
|
||||||
|
|
||||||
|
const PORTS = [3001, 3006, 3007, 3008]
|
||||||
|
|
||||||
|
if (platform() === "win32") {
|
||||||
|
// Windows: use netstat + findstr to find listening PIDs, then taskkill
|
||||||
|
for (const port of PORTS) {
|
||||||
|
try {
|
||||||
|
const out = execSync(`netstat -ano | findstr "LISTENING" | findstr ":${port} "`, { encoding: "utf8", timeout: 5000 })
|
||||||
|
const lines = out.trim().split("\n").filter(Boolean)
|
||||||
|
for (const line of lines) {
|
||||||
|
const parts = line.trim().split(/\s+/)
|
||||||
|
const pid = parts[parts.length - 1]
|
||||||
|
if (pid) {
|
||||||
|
try { execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", timeout: 3000 }); console.log(`Freed port ${port} (PID ${pid})`) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Linux/Mac: use lsof -ti to find PIDs, then kill -9
|
||||||
|
for (const port of PORTS) {
|
||||||
|
try {
|
||||||
|
const pid = execSync(`lsof -ti:${port} 2>/dev/null`, { encoding: "utf8", timeout: 5000 }).trim()
|
||||||
|
if (pid) {
|
||||||
|
execSync(`kill -9 ${pid}`, { stdio: "ignore", timeout: 3000 })
|
||||||
|
console.log(`Freed port ${port} (PID ${pid})`)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// ── Python Runner ──────────────────────────────────────────────────
|
||||||
|
// Detects the system's Python executable (python vs python3) and runs
|
||||||
|
// a given script with arguments. Used by the dev:browser-use npm script.
|
||||||
|
// Avoids shell:true — spawns Python directly with its full path.
|
||||||
|
// Cross-platform: uses where (Windows) or which (Linux/Mac).
|
||||||
|
|
||||||
|
import { execSync, spawn } from "node:child_process"
|
||||||
|
import { platform } from "node:os"
|
||||||
|
import { statSync } from "node:fs"
|
||||||
|
|
||||||
|
function detectPython() {
|
||||||
|
const commonPaths = [
|
||||||
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python313\\python.exe`,
|
||||||
|
`${process.env.LOCALAPPDATA}\\Programs\\Python\\Python312\\python.exe`,
|
||||||
|
`${process.env.ProgramFiles}\\Python313\\python.exe`,
|
||||||
|
`${process.env.ProgramFiles}\\Python312\\python.exe`,
|
||||||
|
]
|
||||||
|
for (const p of commonPaths) {
|
||||||
|
try {
|
||||||
|
statSync(p)
|
||||||
|
return p
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||||
|
for (const cmd of candidates) {
|
||||||
|
try {
|
||||||
|
const out = execSync(platform() === "win32" ? `where ${cmd}` : `which ${cmd}`, { encoding: "utf8", timeout: 5000 })
|
||||||
|
const path = out.trim().split("\n")[0].replace(/\r$/, "")
|
||||||
|
if (path) return path
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.error("Python not found. Install Python 3 from https://python.org")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const PYTHON = detectPython()
|
||||||
|
const script = process.argv[2]
|
||||||
|
const args = process.argv.slice(3)
|
||||||
|
|
||||||
|
if (!script) {
|
||||||
|
console.error("Usage: node run-python.mjs <script> [args...]")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn Python with inherited stdio so the script's output is visible
|
||||||
|
const proc = spawn(PYTHON, [script, ...args], { stdio: "inherit" })
|
||||||
|
proc.on("exit", (code) => process.exit(code ?? 1))
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
// ── One-command Setup ──────────────────────────────────────────────
|
||||||
|
// Run via: npm run setup
|
||||||
|
// Does the following in order:
|
||||||
|
// 1. npm install (Node.js dependencies)
|
||||||
|
// 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).
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function detectPython() {
|
||||||
|
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
|
||||||
|
for (const cmd of candidates) {
|
||||||
|
try {
|
||||||
|
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
|
||||||
|
return cmd
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
console.error("Python not found. Install Python 3 from https://python.org")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectPip(python) {
|
||||||
|
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
|
||||||
|
for (const cmd of candidates) {
|
||||||
|
try {
|
||||||
|
execSync(`${cmd} --version`, { stdio: "pipe", timeout: 5000 })
|
||||||
|
return cmd
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return `${python} -m pip`
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(cmd, label, opts = {}) {
|
||||||
|
console.log(`\n── ${label} ──`)
|
||||||
|
try {
|
||||||
|
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(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
|
||||||
|
|
||||||
|
// 3. Playwright browsers
|
||||||
|
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
|
||||||
|
|
||||||
|
// 4. .env file
|
||||||
|
if (!existsSync(resolve(ROOT, ".env.local"))) {
|
||||||
|
console.log("\n── Creating .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. 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 db:migrate (apply database migrations)")
|
||||||
|
console.log(" 5. Run: npm run dev")
|
||||||
|
console.log("\n=== Setup complete! ===")
|
||||||
+24
-2
@@ -4,8 +4,11 @@ import { SignJWT, jwtVerify } from "jose"
|
|||||||
import pg from "pg"
|
import pg from "pg"
|
||||||
|
|
||||||
const PORT = process.env.SIGNALING_PORT || 3007
|
const PORT = process.env.SIGNALING_PORT || 3007
|
||||||
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "crm-envr-super-secret-key-2026")
|
const rawSecret = process.env.JWT_SECRET
|
||||||
const DATABASE_URL = process.env.DATABASE_URL || "postgres://postgres:postgres@localhost:5432/crm"
|
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 })
|
const pool = new pg.Pool({ connectionString: DATABASE_URL })
|
||||||
|
|
||||||
@@ -143,6 +146,25 @@ io.on("connection", (socket) => {
|
|||||||
io.to(targetSocketId).emit("call:busy", { from: userId })
|
io.to(targetSocketId).emit("call:busy", { from: userId })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
socket.on("message:deleted", async ({ conversationId, messageId, senderId }) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT cp.user_id FROM conversation_participants cp
|
||||||
|
WHERE cp.conversation_id = $1 AND cp.user_id != $2`,
|
||||||
|
[conversationId, senderId || userId],
|
||||||
|
)
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const targetSocketId = onlineUsers.get(row.user_id)
|
||||||
|
if (targetSocketId) {
|
||||||
|
io.to(targetSocketId).emit("message:deleted", { conversationId, messageId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[message:deleted] broadcast msg=${messageId} conv=${conversationId} by=${senderId || userId}`)
|
||||||
|
} catch {
|
||||||
|
console.warn(`[message:deleted] failed to broadcast msg=${messageId}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on("room:join", ({ roomId, displayName }) => {
|
socket.on("room:join", ({ roomId, displayName }) => {
|
||||||
|
|||||||
+451
-69
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Loading CoastIT CRM</title>
|
<title>Loading CoastIT CRM</title>
|
||||||
<style>
|
<style>
|
||||||
|
/* ── Global Reset ──────────────────────────────────────────────── */
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
background: #0a0a1a;
|
background: #0a0a1a;
|
||||||
@@ -18,7 +19,8 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Robot */
|
/* ── Robot Animation ────────────────────────────────────────────── */
|
||||||
|
/* Animated robot mascot that bobs, swings arms, and blinks while loading */
|
||||||
.robot {
|
.robot {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
@@ -128,7 +130,9 @@
|
|||||||
100% { transform: translateY(-4px) rotate(8deg); }
|
100% { transform: translateY(-4px) rotate(8deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status indicators */
|
/* ── Status Indicators ────────────────────────────────────────── */
|
||||||
|
/* Shows AI Server, Scraper, and Frontend status dots side by side.
|
||||||
|
Each transitions from dim → cyan (ready) or red (failed). */
|
||||||
.services {
|
.services {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -207,7 +211,7 @@
|
|||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading text */
|
/* ── Loading Text ──────────────────────────────────────────────── */
|
||||||
.loading-text {
|
.loading-text {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -222,7 +226,9 @@
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Launch gear (inline, below everything) */
|
/* ── Launch Gear ────────────────────────────────────────────────── */
|
||||||
|
/* Appears below the loading text after all services are ready.
|
||||||
|
Shows a spinning gear + "Launching gear!" text, then redirects to /login. */
|
||||||
.launch-overlay {
|
.launch-overlay {
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -255,7 +261,9 @@
|
|||||||
100% { transform: scale(1); opacity: 1; }
|
100% { transform: scale(1); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Error state */
|
/* ── Error State ────────────────────────────────────────────────── */
|
||||||
|
/* When a service fails to start within the timeout (60s), show
|
||||||
|
red text, an error message, and a retry button. */
|
||||||
.loading-text.error {
|
.loading-text.error {
|
||||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
@@ -293,11 +301,135 @@
|
|||||||
.retry-btn.active {
|
.retry-btn.active {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Setup Wizard ──────────────────────────────────────────────── */
|
||||||
|
/* Full-screen 4-step wizard for first-time setup:
|
||||||
|
1. Environment check (Ollama, model, browser, Facebook login)
|
||||||
|
2. Browser auto-detection & selection
|
||||||
|
3. Ollama model pull with progress bar
|
||||||
|
4. Done — close wizard, start normal loading flow */
|
||||||
|
.setup-wizard {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: #0a0a1a;
|
||||||
|
z-index: 200;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.setup-wizard.active { display: flex; }
|
||||||
|
|
||||||
|
.setup-step {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 520px;
|
||||||
|
width: 100%;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
.setup-step.active { display: flex; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.setup-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.setup-dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.setup-dot.done { background: #22d3ee; box-shadow: 0 0 6px rgba(34,211,238,0.5); }
|
||||||
|
.setup-dot.current { background: #6366f1; box-shadow: 0 0 6px rgba(99,102,241,0.5); }
|
||||||
|
|
||||||
|
.setup-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||||||
|
.setup-subtitle { font-size: 14px; opacity: 0.5; margin-bottom: 28px; text-align: center; }
|
||||||
|
|
||||||
|
.setup-card {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
.check-row:last-child { border-bottom: none; }
|
||||||
|
.check-icon { font-size: 18px; min-width: 24px; text-align: center; }
|
||||||
|
.check-label { flex: 1; font-size: 14px; }
|
||||||
|
.check-value { font-size: 12px; opacity: 0.5; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.setup-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
.setup-input:focus { border-color: #6366f1; }
|
||||||
|
|
||||||
|
.setup-btn {
|
||||||
|
padding: 10px 28px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.setup-btn:hover { opacity: 0.85; }
|
||||||
|
.setup-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
.setup-btn.outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
.setup-btn.outline:hover { background: rgba(255,255,255,0.06); }
|
||||||
|
|
||||||
|
.setup-btns { display: flex; gap: 12px; margin-top: 8px; }
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #6366f1, #22d3ee);
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-ok { color: #22d3ee; }
|
||||||
|
.status-err { color: #ef4444; }
|
||||||
|
.status-warn { color: #f59e0b; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Robot -->
|
<!-- ── Robot Mascot ────────────────────────────────────────────── -->
|
||||||
<div class="robot">
|
<div class="robot">
|
||||||
<div class="robot-antenna"></div>
|
<div class="robot-antenna"></div>
|
||||||
<div class="robot-head">
|
<div class="robot-head">
|
||||||
@@ -312,7 +444,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Services -->
|
<!-- ── Service Status Indicators ───────────────────────────────── -->
|
||||||
<div class="services">
|
<div class="services">
|
||||||
<div class="service" id="svc-ai">
|
<div class="service" id="svc-ai">
|
||||||
<span class="service-name">AI Server</span>
|
<span class="service-name">AI Server</span>
|
||||||
@@ -345,109 +477,359 @@
|
|||||||
|
|
||||||
<div class="loading-text" id="loading-text">Loading...</div>
|
<div class="loading-text" id="loading-text">Loading...</div>
|
||||||
|
|
||||||
<!-- Launch gear (inline, below everything) -->
|
<!-- ── Launch Gear ──────────────────────────────────────────── -->
|
||||||
<div class="launch-overlay" id="launch-overlay">
|
<div class="launch-overlay" id="launch-overlay">
|
||||||
<div class="launch-gear"></div>
|
<div class="launch-gear"></div>
|
||||||
<div class="launch-text">🚀 Launching gear!</div>
|
<div class="launch-text">Launching gear!</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="error-msg" id="error-msg"></div>
|
<div class="error-msg" id="error-msg"></div>
|
||||||
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
<button class="retry-btn" id="retry-btn" onclick="location.reload()">Try Again</button>
|
||||||
|
|
||||||
<script>
|
<!-- ══════════════════════════════════════════════════════════════
|
||||||
const MAX_ATTEMPTS = 30;
|
Setup Wizard (4 steps, shown only on first run)
|
||||||
let attempts = 0;
|
══════════════════════════════════════════════════════════════ -->
|
||||||
|
<div class="setup-wizard" id="setup-wizard">
|
||||||
|
<div class="setup-steps">
|
||||||
|
<div class="setup-dot current" id="sdot-1"></div>
|
||||||
|
<div class="setup-dot" id="sdot-2"></div>
|
||||||
|
<div class="setup-dot" id="sdot-3"></div>
|
||||||
|
<div class="setup-dot" id="sdot-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Environment check -->
|
||||||
|
<div class="setup-step active" id="sstep-1">
|
||||||
|
<div class="robot" style="margin-bottom:20px">
|
||||||
|
<div class="robot-antenna"></div>
|
||||||
|
<div class="robot-head"><div class="robot-eye left"></div><div class="robot-eye right"></div></div>
|
||||||
|
<div class="robot-body">
|
||||||
|
<div class="robot-arm left"></div><div class="robot-arm right"></div>
|
||||||
|
<div class="robot-leg left"></div><div class="robot-leg right"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="setup-title">Welcome to CoastIT CRM</div>
|
||||||
|
<div class="setup-subtitle">Let's check your environment before we start</div>
|
||||||
|
<div class="setup-card" id="env-checks">
|
||||||
|
<div class="check-row"><span class="check-icon" id="env-ollama">⏳</span><span class="check-label">Ollama</span><span class="check-value" id="env-ollama-val">checking...</span></div>
|
||||||
|
<div class="check-row"><span class="check-icon" id="env-model">⏳</span><span class="check-label">AI Model</span><span class="check-value" id="env-model-val">checking...</span></div>
|
||||||
|
<div class="check-row"><span class="check-icon" id="env-profile">⏳</span><span class="check-label">Browser Profile</span><span class="check-value" id="env-profile-val">checking...</span></div>
|
||||||
|
<div class="check-row"><span class="check-icon" id="env-fb">⏳</span><span class="check-label">Facebook Login</span><span class="check-value" id="env-fb-val">checking...</span></div>
|
||||||
|
</div>
|
||||||
|
<button class="setup-btn" id="welcome-next" onclick="goStep(2)">Next →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Browser auto-detection & selection -->
|
||||||
|
<div class="setup-step" id="sstep-2">
|
||||||
|
<div class="setup-title">Detect your browser</div>
|
||||||
|
<div class="setup-subtitle">We found these browsers with Facebook login. Click one to select it.</div>
|
||||||
|
<div class="setup-card" id="browser-list">
|
||||||
|
<div id="browser-scanning" style="text-align:center;padding:16px;opacity:0.6">🔍 Scanning for browsers...</div>
|
||||||
|
<div id="browser-results" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div class="setup-btns">
|
||||||
|
<button class="setup-btn outline" onclick="goStep(1)">← Back</button>
|
||||||
|
<button class="setup-btn" id="browser-confirm" onclick="confirmBrowser()" disabled>Confirm →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Ollama Model Pull -->
|
||||||
|
<div class="setup-step" id="sstep-3">
|
||||||
|
<div class="setup-title">AI Model</div>
|
||||||
|
<div class="setup-subtitle">We need to pull the AI model for the scraper to work</div>
|
||||||
|
<div class="setup-card" style="text-align:center">
|
||||||
|
<div style="font-size:14px;margin-bottom:8px;font-weight:600" id="model-name">dolphin-llama3:8b</div>
|
||||||
|
<div class="progress-bar"><div class="progress-fill" id="model-progress-fill"></div></div>
|
||||||
|
<div style="font-size:12px;opacity:0.5;margin-top:6px" id="model-status">Not downloaded yet</div>
|
||||||
|
</div>
|
||||||
|
<div class="setup-btns">
|
||||||
|
<button class="setup-btn outline" onclick="goStep(3)">← Back</button>
|
||||||
|
<button class="setup-btn" id="model-pull-btn" onclick="pullModel()">Pull Model</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Done → Launch -->
|
||||||
|
<div class="setup-step" id="sstep-4">
|
||||||
|
<div style="font-size:64px;margin-bottom:16px">🎉</div>
|
||||||
|
<div class="setup-title">All set!</div>
|
||||||
|
<div class="setup-subtitle">Your environment is configured. We'll now start all services.</div>
|
||||||
|
<div class="setup-card" id="final-checks" style="text-align:center">
|
||||||
|
<div style="font-size:13px;opacity:0.7" id="final-summary">All checks passed</div>
|
||||||
|
</div>
|
||||||
|
<button class="setup-btn" onclick="finishSetup()" style="margin-top:8px">Launch 🚀</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
// Client-side JavaScript
|
||||||
|
// ══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── State ──
|
||||||
|
let setupData = null;
|
||||||
|
let currentStep = 1;
|
||||||
|
const TOTAL_STEPS = 4;
|
||||||
|
let selectedBrowser = "";
|
||||||
|
|
||||||
|
// ── Normal loading state ──
|
||||||
|
const MAX_ATTEMPTS = 30; // 30 attempts × 2s = 60s timeout
|
||||||
|
let attempts = 0;
|
||||||
const CHECKS = [
|
const CHECKS = [
|
||||||
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
{ id: 'ai', key: 'ai', ready: false, failed: false },
|
||||||
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
{ id: 'scraper', key: 'scraper', ready: false, failed: false },
|
||||||
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
{ id: 'frontend',key: 'frontend', ready: false, failed: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
const MSGS = { ai: 'AI Ready', scraper: 'Scraper Ready', frontend: 'Frontend Ready' };
|
||||||
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
|
const NAMES = { ai: 'AI Server', scraper: 'Scraper', frontend: 'Frontend' };
|
||||||
|
const BROWSE_ICONS = { firefox: '🦊', opera: '🔵', chrome: '🌐', edge: '🔷' };
|
||||||
|
|
||||||
|
// ── Step navigation ──
|
||||||
|
function goStep(n) {
|
||||||
|
currentStep = n;
|
||||||
|
document.querySelectorAll('.setup-step').forEach((el, i) => {
|
||||||
|
el.classList.toggle('active', i + 1 === n);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.setup-dot').forEach((el, i) => {
|
||||||
|
el.classList.toggle('current', i + 1 === n);
|
||||||
|
el.classList.toggle('done', i + 1 < n);
|
||||||
|
});
|
||||||
|
if (n === 2) renderBrowserCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 1: Welcome + Environment check ──
|
||||||
|
// Fetches /setup/status from the AI server, displays checkmarks/crosses
|
||||||
|
// for each environment requirement. If first_run is false, skips the
|
||||||
|
// wizard entirely and goes straight to the normal loading flow.
|
||||||
|
async function checkEnv() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/status');
|
||||||
|
setupData = await res.json();
|
||||||
|
} catch {
|
||||||
|
setupData = { first_run: true, ollama_running: false, model_available: false, facebook_logged_in: false, browsers: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
setEnvStatus('ollama', setupData.ollama_running, setupData.ollama_running ? 'Running' : 'Not running');
|
||||||
|
setEnvStatus('model', setupData.model_available, setupData.model_available ? `${setupData.model_name} ✓` : 'Not pulled');
|
||||||
|
|
||||||
|
// Browser summary
|
||||||
|
const detected = Object.entries(setupData.browsers || {}).filter(([, v]) => v.path)
|
||||||
|
const loggedIn = detected.filter(([, v]) => v.logged_in)
|
||||||
|
const browserSummary = loggedIn.length
|
||||||
|
? loggedIn.map(([b]) => `${b.charAt(0).toUpperCase()+b.slice(1)} ✓`).join(', ')
|
||||||
|
: detected.length ? 'Found but not logged into Facebook' : 'None detected'
|
||||||
|
setEnvStatus('profile', detected.length > 0, browserSummary)
|
||||||
|
setEnvStatus('fb', setupData.facebook_logged_in, setupData.facebook_logged_in ? 'Logged in' : loggedIn.length ? '' : 'Not checked')
|
||||||
|
|
||||||
|
if (!setupData.first_run) {
|
||||||
|
document.getElementById('setup-wizard').classList.remove('active');
|
||||||
|
startNormalFlow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.getElementById('setup-wizard').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEnvStatus(id, ok, label) {
|
||||||
|
document.getElementById('env-' + id).textContent = ok ? '✅' : '❌';
|
||||||
|
document.getElementById('env-' + id + '-val').textContent = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 2: Browser selection ──
|
||||||
|
// Renders clickable cards for each detected browser.
|
||||||
|
// Auto-selects the first one that has Facebook login.
|
||||||
|
// No manual path input needed — all detection is automatic.
|
||||||
|
function renderBrowserCards() {
|
||||||
|
const scanning = document.getElementById('browser-scanning');
|
||||||
|
const results = document.getElementById('browser-results');
|
||||||
|
const browsers = setupData.browsers || {};
|
||||||
|
const detected = Object.entries(browsers).filter(([, v]) => v.path)
|
||||||
|
|
||||||
|
if (detected.length === 0) {
|
||||||
|
scanning.textContent = '❌ No browsers with Facebook detected. Log in on your browser and click Retry.';
|
||||||
|
const retryBtn = document.createElement('button');
|
||||||
|
retryBtn.className = 'setup-btn';
|
||||||
|
retryBtn.textContent = 'Retry Detection';
|
||||||
|
retryBtn.onclick = () => location.reload();
|
||||||
|
scanning.after(retryBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scanning.style.display = 'none';
|
||||||
|
results.style.display = 'block';
|
||||||
|
|
||||||
|
// Auto-select first logged-in browser, or first detected
|
||||||
|
const firstLoggedIn = detected.find(([, v]) => v.logged_in)
|
||||||
|
selectedBrowser = firstLoggedIn ? firstLoggedIn[0] : detected[0][0]
|
||||||
|
|
||||||
|
results.innerHTML = detected.map(([name, info]) => {
|
||||||
|
const isSel = name === selectedBrowser
|
||||||
|
const icon = BROWSE_ICONS[name] || '🌐'
|
||||||
|
const status = info.logged_in ? '✅ Logged in' : info.path ? '⚠️ Not logged in' : '❌ Not found'
|
||||||
|
const pathShort = info.path ? info.path.length > 45 ? '...' + info.path.slice(-42) : info.path : ''
|
||||||
|
return `<div class="browser-card ${isSel ? 'selected' : ''}" data-browser="${name}" onclick="pickBrowser('${name}')" style="display:flex;align-items:center;gap:12px;padding:12px;border:2px solid ${isSel ? '#6366f1' : 'rgba(255,255,255,0.08)'};border-radius:10px;margin-bottom:8px;cursor:pointer;transition:all 0.2s">
|
||||||
|
<span style="font-size:24px">${icon}</span>
|
||||||
|
<div style="flex:1"><strong style="text-transform:capitalize">${name}</strong><div style="font-size:12px;opacity:0.5;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${pathShort}</div></div>
|
||||||
|
<span style="font-size:13px">${status}</span>
|
||||||
|
</div>`
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
document.getElementById('browser-confirm').disabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBrowser(name) {
|
||||||
|
selectedBrowser = name
|
||||||
|
document.querySelectorAll('.browser-card').forEach(el => {
|
||||||
|
el.style.borderColor = el.dataset.browser === name ? '#6366f1' : 'rgba(255,255,255,0.08)'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saves the selected browser to .env.local via the AI server's /setup/profile endpoint
|
||||||
|
async function confirmBrowser() {
|
||||||
|
if (!selectedBrowser) return
|
||||||
|
const btn = document.getElementById('browser-confirm')
|
||||||
|
btn.textContent = 'Saving...'
|
||||||
|
btn.disabled = true
|
||||||
|
|
||||||
|
const path = setupData.browsers[selectedBrowser].path
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/profile', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ browser: selectedBrowser, path })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
setupData.selected_browser = selectedBrowser
|
||||||
|
goStep(3)
|
||||||
|
} else {
|
||||||
|
alert('Failed to save: ' + (data.error || 'unknown'))
|
||||||
|
btn.disabled = false
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
alert('Could not save profile')
|
||||||
|
btn.disabled = false
|
||||||
|
}
|
||||||
|
btn.textContent = 'Confirm →'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 3: Ollama Model Pull ──
|
||||||
|
// Kicks off an "ollama pull" via the AI server and polls for progress.
|
||||||
|
let pullPolling = false;
|
||||||
|
async function pullModel() {
|
||||||
|
if (pullPolling) return;
|
||||||
|
const btn = document.getElementById('model-pull-btn');
|
||||||
|
const fill = document.getElementById('model-progress-fill');
|
||||||
|
const status = document.getElementById('model-status');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Starting...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/ollama/pull', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === 'already_running') { btn.textContent = 'Already running'; return }
|
||||||
|
btn.textContent = 'Downloading...';
|
||||||
|
pullPolling = true;
|
||||||
|
pollPullProgress();
|
||||||
|
} catch {
|
||||||
|
status.textContent = 'Failed to start download';
|
||||||
|
status.className = 'status-err';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Retry';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pollPullProgress() {
|
||||||
|
const fill = document.getElementById('model-progress-fill');
|
||||||
|
const status = document.getElementById('model-status');
|
||||||
|
const btn = document.getElementById('model-pull-btn');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/setup/ollama/pull/progress');
|
||||||
|
const data = await res.json();
|
||||||
|
fill.style.width = data.progress + '%';
|
||||||
|
if (data.status === 'done') {
|
||||||
|
status.textContent = '✅ Model downloaded!';
|
||||||
|
status.className = 'status-ok';
|
||||||
|
btn.textContent = 'Done';
|
||||||
|
setupData.model_available = true;
|
||||||
|
pullPolling = false;
|
||||||
|
setTimeout(() => goStep(4), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.status === 'failed') {
|
||||||
|
status.textContent = '❌ Download failed: ' + data.message;
|
||||||
|
status.className = 'status-err';
|
||||||
|
btn.textContent = 'Retry';
|
||||||
|
btn.disabled = false;
|
||||||
|
pullPolling = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
status.textContent = data.message || `Downloading... ${data.progress}%`;
|
||||||
|
setTimeout(pollPullProgress, 1000);
|
||||||
|
} catch {
|
||||||
|
status.textContent = 'Checking...';
|
||||||
|
setTimeout(pollPullProgress, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Step 4: Finish Setup ──
|
||||||
|
// Closes the wizard and starts the normal loading flow.
|
||||||
|
function finishSetup() {
|
||||||
|
document.getElementById('setup-wizard').classList.remove('active');
|
||||||
|
startNormalFlow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Normal Loading Flow ──
|
||||||
|
// Polls /status every 2 seconds for up to 60 seconds.
|
||||||
|
// Tracks each service's state (ready/waiting/failed).
|
||||||
|
// All ready → show launch gear, redirect to /login after 5s.
|
||||||
|
// Any failed → show error message + retry button.
|
||||||
|
function startNormalFlow() { poll() }
|
||||||
|
|
||||||
async function checkAllServices() {
|
async function checkAllServices() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/status', { cache: 'no-store' });
|
const res = await fetch('/status', { cache: 'no-store' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) svc.ready = data[svc.key] === true;
|
||||||
svc.ready = data[svc.key] === true;
|
} catch { for (const svc of CHECKS) svc.ready = false }
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
for (const svc of CHECKS) svc.ready = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUI() {
|
function updateUI() {
|
||||||
let allReady = true;
|
let allReady = true, anyFailed = false;
|
||||||
let anyFailed = false;
|
|
||||||
const failedNames = [];
|
const failedNames = [];
|
||||||
|
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) {
|
||||||
const el = document.getElementById('svc-' + svc.id);
|
const el = document.getElementById('svc-' + svc.id);
|
||||||
const statusText = document.getElementById('status-' + svc.id);
|
const st = document.getElementById('status-' + svc.id);
|
||||||
el.classList.remove('ready', 'failed');
|
el.classList.remove('ready', 'failed');
|
||||||
|
if (svc.ready) { el.classList.add('ready'); st.textContent = MSGS[svc.id] }
|
||||||
if (svc.ready) {
|
else if (svc.failed) { el.classList.add('failed'); st.textContent = 'Failed'; anyFailed = true; allReady = false; failedNames.push(NAMES[svc.id]) }
|
||||||
el.classList.add('ready');
|
else { st.textContent = 'waiting...'; allReady = false }
|
||||||
statusText.textContent = MSGS[svc.id];
|
|
||||||
} else if (svc.failed) {
|
|
||||||
el.classList.add('failed');
|
|
||||||
statusText.textContent = 'Failed';
|
|
||||||
anyFailed = true;
|
|
||||||
allReady = false;
|
|
||||||
failedNames.push(NAMES[svc.id]);
|
|
||||||
} else {
|
|
||||||
statusText.textContent = 'waiting...';
|
|
||||||
allReady = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const lt = document.getElementById('loading-text'), em = document.getElementById('error-msg'), rb = document.getElementById('retry-btn'), lo = document.getElementById('launch-overlay');
|
||||||
const loadingText = document.getElementById('loading-text');
|
|
||||||
const errorMsg = document.getElementById('error-msg');
|
|
||||||
const retryBtn = document.getElementById('retry-btn');
|
|
||||||
|
|
||||||
if (anyFailed) {
|
if (anyFailed) {
|
||||||
loadingText.className = 'loading-text error';
|
lt.className = 'loading-text error'; lt.textContent = 'Something went wrong';
|
||||||
loadingText.textContent = 'Something went wrong';
|
em.textContent = failedNames.join(', ') + ' failed to start. Check your terminal.';
|
||||||
errorMsg.textContent = failedNames.join(', ') + ' failed to start. Check your terminal for details.';
|
em.classList.add('active'); rb.classList.add('active'); lo.classList.remove('active');
|
||||||
errorMsg.classList.add('active');
|
|
||||||
retryBtn.classList.add('active');
|
|
||||||
document.getElementById('launch-overlay').classList.remove('active');
|
|
||||||
} else if (allReady) {
|
} else if (allReady) {
|
||||||
loadingText.className = 'loading-text';
|
lt.className = 'loading-text'; lt.textContent = 'All systems ready!';
|
||||||
loadingText.textContent = 'All systems ready!';
|
em.classList.remove('active'); rb.classList.remove('active');
|
||||||
errorMsg.classList.remove('active');
|
lo.classList.add('active');
|
||||||
retryBtn.classList.remove('active');
|
|
||||||
document.getElementById('launch-overlay').classList.add('active');
|
|
||||||
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
setTimeout(() => { window.location.href = 'http://localhost:3006/login'; }, 5000);
|
||||||
} else {
|
} else {
|
||||||
loadingText.className = 'loading-text';
|
lt.className = 'loading-text';
|
||||||
const ready = CHECKS.filter(s => s.ready).length;
|
lt.textContent = `Loading... (${CHECKS.filter(s => s.ready).length}/${CHECKS.length})`;
|
||||||
loadingText.textContent = `Loading... (${ready}/${CHECKS.length})`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function poll() {
|
async function poll() {
|
||||||
attempts++;
|
attempts++;
|
||||||
if (attempts > MAX_ATTEMPTS) {
|
if (attempts > MAX_ATTEMPTS) {
|
||||||
for (const svc of CHECKS) {
|
for (const svc of CHECKS) { if (!svc.ready) svc.failed = true }
|
||||||
if (!svc.ready) svc.failed = true;
|
updateUI(); return;
|
||||||
}
|
|
||||||
updateUI();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
await checkAllServices(); updateUI();
|
||||||
await checkAllServices();
|
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed))
|
||||||
updateUI();
|
|
||||||
|
|
||||||
if (!CHECKS.every(s => s.ready) && !CHECKS.some(s => s.failed)) {
|
|
||||||
setTimeout(poll, 2000);
|
setTimeout(poll, 2000);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
poll();
|
// ── Boot ──
|
||||||
|
// Entry point: check environment → shows wizard or starts normal flow
|
||||||
|
checkEnv();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,85 +1,119 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback, useRef } from "react"
|
||||||
import { AIChat } from "@/components/ai/ai-chat"
|
import { AIChat, type AIChatHandle } from "@/components/ai/ai-chat"
|
||||||
import { JobSelector } from "@/components/ai/job-selector"
|
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() {
|
export default function AIAssistantPage() {
|
||||||
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
|
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) => {
|
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||||
setSelectedJob(job)
|
setSelectedJob(job)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
|
||||||
|
setSearching(true)
|
||||||
|
const keyword = job.keywords[0]
|
||||||
|
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 leadsText = data.leads.map((lead: any, i: number) =>
|
||||||
|
`**${i + 1}.** ${lead.author || "Unknown"}\n> ${(lead.content || "").slice(0, 300)}\n> 🔗 ${lead.url || "(no link available)"}`
|
||||||
|
).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 (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
|
||||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
<div className="flex items-center gap-4">
|
||||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
<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)"}}>
|
||||||
</div>
|
<Bot className="h-5 w-5 text-white" />
|
||||||
<div>
|
</div>
|
||||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
<div>
|
||||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
|
||||||
</div>
|
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1 flex">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
||||||
<AIChat />
|
<span className="text-[#22c55e] text-xs font-medium">Online</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-px h-5 bg-border" />
|
||||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
|
||||||
<div>
|
<span className="text-muted-foreground text-xs">Local AI Model</span>
|
||||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
||||||
<Target className="h-3.5 w-3.5" />
|
|
||||||
Target Job
|
|
||||||
</h3>
|
|
||||||
<JobSelector onSelect={handleJobSelect} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedJob && (
|
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedJob.keywords.map((kw, i) => (
|
|
||||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
|
||||||
{kw}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
|
||||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
|
||||||
<Lightbulb className="h-3.5 w-3.5" />
|
|
||||||
Tips
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
|
||||||
<li className="flex gap-2">
|
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
|
||||||
Ask for cold email templates for a specific job
|
|
||||||
</li>
|
|
||||||
<li className="flex gap-2">
|
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
|
||||||
Request objection handling tips
|
|
||||||
</li>
|
|
||||||
<li className="flex gap-2">
|
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
|
||||||
Ask for outreach strategies per industry
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,996 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, Component, type ReactNode } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { CalendarEvent, EventType } from "@/types"
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { useNotifications } from "@/providers/notification-provider"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||||
|
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||||
|
ArrowUpRight, Zap, StickyNote, Globe,
|
||||||
|
} from "lucide-react"
|
||||||
|
|
||||||
|
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||||
|
call: Phone,
|
||||||
|
follow_up: Clock,
|
||||||
|
website_creation: Globe,
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventVars(type: EventType) {
|
||||||
|
return `--event-${type}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventBgStyle(type: EventType): React.CSSProperties {
|
||||||
|
const v = eventVars(type)
|
||||||
|
return {
|
||||||
|
backgroundColor: `hsl(var(${v}) / 0.1)`,
|
||||||
|
color: `hsl(var(${v}))`,
|
||||||
|
borderColor: `hsl(var(${v}) / 0.25)`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDotStyle(type: EventType): React.CSSProperties {
|
||||||
|
return { backgroundColor: `hsl(var(--event-${type}))` }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaysInMonth(year: number, month: number) {
|
||||||
|
return new Date(year, month + 1, 0).getDate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFirstDayOfMonth(year: number, month: number) {
|
||||||
|
return new Date(year, month, 1).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
const MONTHS = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||||
|
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
|
||||||
|
|
||||||
|
function formatTime(iso: string) {
|
||||||
|
return new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CalendarPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useUser()
|
||||||
|
const { addNotification } = useNotifications()
|
||||||
|
const [events, setEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [upcomingEvents, setUpcomingEvents] = useState<CalendarEvent[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [today] = useState(new Date())
|
||||||
|
const [currentMonth, setCurrentMonth] = useState(today.getMonth())
|
||||||
|
const [currentYear, setCurrentYear] = useState(today.getFullYear())
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null)
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
|
||||||
|
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>("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("")
|
||||||
|
const [notesSaving, setNotesSaving] = useState(false)
|
||||||
|
|
||||||
|
const fetchMonthEvents = useCallback(async () => {
|
||||||
|
const start = new Date(currentYear, currentMonth, 1).toISOString()
|
||||||
|
const end = new Date(currentYear, currentMonth + 1, 0, 23, 59, 59).toISOString()
|
||||||
|
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setEvents(data.events || [])
|
||||||
|
}
|
||||||
|
}, [currentYear, currentMonth])
|
||||||
|
|
||||||
|
const fetchUpcomingEvents = useCallback(async () => {
|
||||||
|
const start = new Date().toISOString()
|
||||||
|
const end = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
const res = await fetch(`/api/events?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}&status=scheduled`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUpcomingEvents((data.events || []).slice(0, 10))
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) { router.push("/login"); return }
|
||||||
|
fetch("/api/event-users")
|
||||||
|
.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(() => {
|
||||||
|
setLoading(true)
|
||||||
|
fetchMonthEvents().catch(() => toast.error("Failed to load events")).finally(() => setLoading(false))
|
||||||
|
}, [fetchMonthEvents])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUpcomingEvents().catch(() => {})
|
||||||
|
}, [fetchUpcomingEvents])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
fetchMonthEvents().catch(() => {})
|
||||||
|
fetchUpcomingEvents().catch(() => {})
|
||||||
|
}, 15000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [fetchMonthEvents, fetchUpcomingEvents])
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(currentYear, currentMonth)
|
||||||
|
const firstDay = getFirstDayOfMonth(currentYear, currentMonth)
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
if (currentMonth === 0) { setCurrentMonth(11); setCurrentYear(currentYear - 1) }
|
||||||
|
else setCurrentMonth(currentMonth - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
if (currentMonth === 11) { setCurrentMonth(0); setCurrentYear(currentYear + 1) }
|
||||||
|
else setCurrentMonth(currentMonth + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && e.startTime.startsWith(dateStr))
|
||||||
|
}, [events, currentYear, currentMonth])
|
||||||
|
|
||||||
|
const isToday = (day: number) => {
|
||||||
|
return today.getDate() === day && today.getMonth() === currentMonth && today.getFullYear() === currentYear
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreateDialog = (day?: number) => {
|
||||||
|
setEditingEvent(null)
|
||||||
|
setFormTitle("")
|
||||||
|
setFormDescription("")
|
||||||
|
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")
|
||||||
|
setDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEditDialog = (event: CalendarEvent) => {
|
||||||
|
setEditingEvent(event)
|
||||||
|
setFormTitle(event.title)
|
||||||
|
setFormDescription(event.description || "")
|
||||||
|
setFormType(event.eventType)
|
||||||
|
setFormDuration(String(event.durationMinutes || 30))
|
||||||
|
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()) {
|
||||||
|
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 body: Record<string, unknown> = {
|
||||||
|
title: formTitle.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
eventType: formType,
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
res = await fetch(`/api/events/${editingEvent.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
res = await fetch("/api/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error("Failed to save")
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
if (editingEvent) {
|
||||||
|
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])
|
||||||
|
if (startTime) {
|
||||||
|
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||||
|
}
|
||||||
|
toast.success("Event created")
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save event")
|
||||||
|
} finally {
|
||||||
|
setFormSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateEventStatus = async (event: CalendarEvent, newStatus: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${event.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: newStatus }),
|
||||||
|
})
|
||||||
|
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, developer: e.developer, lead: e.lead } : e))
|
||||||
|
toast.success(`Event ${newStatus}`)
|
||||||
|
setDetailEvent(null)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to update event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNotes = async (event: CalendarEvent) => {
|
||||||
|
if (notesText === (event.participantNotes || "")) { setEditNotes(false); return }
|
||||||
|
setNotesSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${event.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ participantNotes: notesText || null }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed to save")
|
||||||
|
const data = await res.json()
|
||||||
|
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
|
if (detailEvent && detailEvent.id === event.id) {
|
||||||
|
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||||
|
}
|
||||||
|
toast.success("Notes saved")
|
||||||
|
setEditNotes(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to save notes")
|
||||||
|
} finally {
|
||||||
|
setNotesSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteEvent = async (event: CalendarEvent) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/events/${event.id}`, { method: "DELETE" })
|
||||||
|
if (!res.ok) throw new Error("Failed to delete")
|
||||||
|
setEvents((prev) => prev.filter((e) => e.id !== event.id))
|
||||||
|
toast.success("Event deleted")
|
||||||
|
setDetailEvent(null)
|
||||||
|
setDialogOpen(false)
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to delete event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarDays: (number | null)[] = []
|
||||||
|
for (let i = 0; i < firstDay; i++) calendarDays.push(null)
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) calendarDays.push(d)
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-b from-background via-background to-muted/20">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-b shrink-0 bg-gradient-to-r from-background via-accent/5 to-background backdrop-blur-sm shadow-[0_1px_3px_-1px_hsl(var(--border))]">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<div className="p-2 rounded-lg bg-gradient-to-br from-primary/20 to-primary/5 text-primary shadow-sm ring-1 ring-primary/15">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-lg font-bold tracking-tight">Calendar</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 p-0.5 rounded-lg border bg-card/60 shadow-sm">
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={prevMonth}>
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm font-bold min-w-[130px] text-center select-none tracking-tight px-1">
|
||||||
|
{MONTHS[currentMonth]} {currentYear}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-md hover:bg-accent/60 hover:scale-105 active:scale-95 transition-all" onClick={nextMonth}>
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" className="text-xs h-7 px-2 hover:bg-accent/40 hover:scale-105 active:scale-95 transition-all" onClick={() => { setCurrentMonth(today.getMonth()); setCurrentYear(today.getFullYear()) }}>
|
||||||
|
Today
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => openCreateDialog()} size="sm" className="shadow-sm hover:shadow-md hover:scale-[1.02] active:scale-[0.98] transition-all bg-gradient-to-r from-primary to-primary/90 h-9">
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" /> New Event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body - flex row with independent scroll */}
|
||||||
|
<div className="flex-1 flex overflow-hidden p-4 lg:p-6 gap-4 lg:gap-6">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary/60" />
|
||||||
|
<div className="absolute inset-0 animate-pulse rounded-full bg-primary/5" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground animate-pulse">Loading calendar...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Left: Calendar Grid */}
|
||||||
|
<div className="flex-1 flex flex-col gap-4 min-w-0 overflow-hidden">
|
||||||
|
{/* Stats bar */}
|
||||||
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
|
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-primary/5 border border-primary/10 shadow-sm">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||||
|
<span className="text-sm font-bold text-primary">{events.filter(e => e.status === "scheduled").length}</span>
|
||||||
|
<span className="text-[11px] text-primary/60 font-medium">scheduled</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-emerald-500/5 border border-emerald-500/10 shadow-sm">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-sm font-bold text-emerald-600 dark:text-emerald-400">{events.filter(e => e.status === "completed").length}</span>
|
||||||
|
<span className="text-[11px] text-emerald-600/60 dark:text-emerald-400/60 font-medium">completed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 px-3.5 py-2 rounded-lg bg-muted/30 border shadow-sm">
|
||||||
|
<div className="h-2 w-2 rounded-full bg-muted-foreground/30" />
|
||||||
|
<span className="text-sm font-bold text-foreground/70">{events.length}</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground/50 font-medium">total</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Calendar grid wrapper - scrollable */}
|
||||||
|
<div className="flex-1 overflow-auto rounded-xl border bg-card shadow-sm">
|
||||||
|
<div className="min-w-[600px]">
|
||||||
|
{/* Day headers */}
|
||||||
|
<div className="grid grid-cols-7 sticky top-0 z-10 bg-gradient-to-r from-muted/30 via-background to-muted/30 border-b border-border/40">
|
||||||
|
{DAYS.map((d, i) => (
|
||||||
|
<div key={d} className={cn(
|
||||||
|
"px-3 py-2.5 text-[11px] font-semibold text-muted-foreground/50 text-center tracking-widest uppercase",
|
||||||
|
i === 0 && "text-red-500/40 dark:text-red-400/40",
|
||||||
|
i === 6 && "text-red-500/40 dark:text-red-400/40",
|
||||||
|
)}>
|
||||||
|
{d}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Day cells */}
|
||||||
|
<div className="grid grid-cols-7">
|
||||||
|
{calendarDays.map((day, i) => {
|
||||||
|
if (day === null) return <div key={`empty-${i}`} className="min-h-[110px] border-r border-b border-border/40 bg-gradient-to-b from-muted/[0.01] to-transparent" />
|
||||||
|
const dayEvents = getEventsForDay(day)
|
||||||
|
const todayMark = isToday(day)
|
||||||
|
const hasEvents = dayEvents.length > 0
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={day}
|
||||||
|
className={cn(
|
||||||
|
"min-h-[110px] p-1.5 border-r border-b border-border/40 cursor-pointer transition-all duration-150 relative group",
|
||||||
|
"hover:bg-accent/20 hover:shadow-[inset_0_0_0_1px_hsl(var(--border))]",
|
||||||
|
todayMark && "bg-gradient-to-b from-primary/[0.04] to-transparent",
|
||||||
|
)}
|
||||||
|
onClick={() => { setSelectedDate(new Date(currentYear, currentMonth, day)); openCreateDialog(day) }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 px-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center h-6 w-6 text-xs font-medium rounded-full transition-all duration-200",
|
||||||
|
todayMark && "bg-primary text-primary-foreground text-[11px] font-bold shadow-md ring-2 ring-primary/30 scale-110",
|
||||||
|
!todayMark && "text-muted-foreground/70 group-hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
{hasEvents && (
|
||||||
|
<span className="text-[10px] font-semibold text-muted-foreground/30 group-hover:text-muted-foreground/60 transition-all">
|
||||||
|
{dayEvents.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{dayEvents.slice(0, 3).map((event) => {
|
||||||
|
const Icon = EVENT_ICONS[event.eventType]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setDetailEvent(event) }}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left text-[10px] leading-tight flex items-center gap-1 rounded transition-all duration-150",
|
||||||
|
"hover:shadow-sm hover:scale-[1.02] active:scale-[0.97] group/pill",
|
||||||
|
event.status !== "scheduled" && "opacity-40",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-5 rounded-full shrink-0" style={eventDotStyle(event.eventType)} />
|
||||||
|
<div style={eventBgStyle(event.eventType)}
|
||||||
|
className="flex-1 flex items-center gap-1 px-1.5 py-0.5 rounded-r min-w-0"
|
||||||
|
>
|
||||||
|
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
||||||
|
<span className="truncate font-medium">{event.title}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{dayEvents.length > 3 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation() }}
|
||||||
|
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground pl-0.5 font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
+{dayEvents.length - 3} more
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Upcoming sidebar */}
|
||||||
|
<div className="w-72 lg:w-80 shrink-0 flex flex-col gap-4 overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between shrink-0">
|
||||||
|
<h2 className="text-sm font-bold flex items-center gap-2 tracking-tight">
|
||||||
|
<ArrowUpRight className="h-4 w-4 text-primary/70" />
|
||||||
|
Upcoming
|
||||||
|
</h2>
|
||||||
|
{upcomingEvents.length > 0 && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] font-semibold px-2 py-0.5 ring-1 ring-border/50">
|
||||||
|
{upcomingEvents.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{upcomingEvents.length === 0 ? (
|
||||||
|
<Card className="p-6 border-dashed bg-card/50">
|
||||||
|
<div className="flex flex-col items-center gap-2.5 text-center">
|
||||||
|
<div className="p-3 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 ring-1 ring-border/20">
|
||||||
|
<Calendar className="h-6 w-6 text-muted-foreground/30" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">No upcoming events</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground/40 mt-0.5">Schedule one to get started</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => openCreateDialog()} className="hover:scale-105 active:scale-95 transition-all shadow-sm">
|
||||||
|
<Plus className="h-3 w-3 mr-1.5" /> Schedule event
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{upcomingEvents.map((event) => {
|
||||||
|
const Icon = EVENT_ICONS[event.eventType]
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={event.id}
|
||||||
|
onClick={() => setDetailEvent(event)}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left rounded-lg border bg-card/80 hover:bg-card transition-all duration-150 overflow-hidden",
|
||||||
|
"hover:shadow-md hover:border-accent-foreground/10 active:scale-[0.98]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-0.5 shrink-0" style={eventDotStyle(event.eventType)} />
|
||||||
|
<div className="flex-1 p-3">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className={cn(
|
||||||
|
"p-1.5 rounded shrink-0 ring-1 ring-border/20",
|
||||||
|
event.userId === user?.id ? "bg-primary/5" : "bg-muted",
|
||||||
|
)}>
|
||||||
|
<Icon className={cn(
|
||||||
|
"h-3.5 w-3.5",
|
||||||
|
event.userId === user?.id ? "text-primary/60" : "text-muted-foreground/40",
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<p className="text-sm font-semibold truncate">{event.title}</p>
|
||||||
|
<span className={cn(
|
||||||
|
"text-[9px] font-semibold px-1.5 py-0.5 rounded capitalize shrink-0",
|
||||||
|
event.status === "scheduled" && "bg-primary/10 text-primary",
|
||||||
|
event.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||||||
|
event.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400",
|
||||||
|
)}>
|
||||||
|
{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.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" />
|
||||||
|
{event.durationMinutes} min
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create/Edit Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
"p-3 rounded-xl border shadow-sm",
|
||||||
|
editingEvent
|
||||||
|
? "bg-gradient-to-br from-muted to-muted/50 text-muted-foreground border-border"
|
||||||
|
: "bg-gradient-to-br from-primary/15 to-primary/5 text-primary border-primary/20",
|
||||||
|
)}>
|
||||||
|
{editingEvent ? <Clock className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DialogTitle className="text-lg font-bold">{editingEvent ? "Edit Event" : "Create Event"}</DialogTitle>
|
||||||
|
<p className="text-xs text-muted-foreground/60 mt-0.5">
|
||||||
|
{editingEvent ? "Update the event details below" : "Fill in the details for your new event"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-5 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="title" className="text-xs font-semibold">Title</Label>
|
||||||
|
<Input id="title" value={formTitle} onChange={(e) => setFormTitle(e.target.value)} placeholder="e.g. Product demo with client" className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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={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>
|
||||||
|
<Select value={formType} onValueChange={(v: EventType) => setFormType(v)}>
|
||||||
|
<SelectTrigger id="type" className="h-10">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="website_creation">Website Creation</SelectItem>
|
||||||
|
<SelectItem value="call">Call</SelectItem>
|
||||||
|
<SelectItem value="follow_up">Follow Up</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={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="15">15 min</SelectItem>
|
||||||
|
<SelectItem value="30">30 min</SelectItem>
|
||||||
|
<SelectItem value="45">45 min</SelectItem>
|
||||||
|
<SelectItem value="60">1 hour</SelectItem>
|
||||||
|
<SelectItem value="90">1.5 hours</SelectItem>
|
||||||
|
<SelectItem value="120">2 hours</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="participant" className="text-xs font-semibold">Participant</Label>
|
||||||
|
<Select value={formParticipantId || "__none__"} onValueChange={(v) => setFormParticipantId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger id="participant" className="h-10">
|
||||||
|
<SelectValue placeholder="Select a participant…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.email})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</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" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
||||||
|
<div>
|
||||||
|
{editingEvent && (
|
||||||
|
<Button variant="destructive" size="sm" onClick={() => deleteEvent(editingEvent)} className="shadow-sm">
|
||||||
|
Delete event
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button onClick={handleSave} disabled={formSaving} className="shadow-sm">
|
||||||
|
{formSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
{editingEvent ? "Update Event" : "Create Event"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Event Detail Dialog */}
|
||||||
|
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||||
|
{detailEvent && (
|
||||||
|
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||||
|
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-5 w-5" /> : <Calendar className="h-5 w-5" /> })()}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<DialogTitle className="text-lg font-bold truncate">{detailEvent.title}</DialogTitle>
|
||||||
|
<span className={cn(
|
||||||
|
"inline-flex text-[10px] font-semibold px-2 py-0.5 rounded-full capitalize mt-0.5 ring-1",
|
||||||
|
detailEvent.status === "scheduled" && "bg-primary/10 text-primary ring-primary/20",
|
||||||
|
detailEvent.status === "completed" && "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 ring-emerald-500/20",
|
||||||
|
detailEvent.status === "cancelled" && "bg-red-500/10 text-red-600 dark:text-red-400 ring-red-500/20",
|
||||||
|
)}>
|
||||||
|
{detailEvent.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Date / Time */}
|
||||||
|
{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>
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
{detailEvent.durationMinutes && (
|
||||||
|
<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">
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
Duration
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold">{detailEvent.durationMinutes} min</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 capitalize">
|
||||||
|
{(() => { const Icon = EVENT_ICONS[detailEvent.eventType]; return Icon ? <Icon className="h-3.5 w-3.5" /> : <Calendar className="h-3.5 w-3.5" /> })()}
|
||||||
|
Type
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-bold capitalize">{detailEvent.eventType.replace("_", " ")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">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">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Linked Lead</p>
|
||||||
|
<p className="text-sm font-bold">{detailEvent.lead.contactName} — {detailEvent.lead.companyName}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organizer & Participant - cross-linked */}
|
||||||
|
<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">
|
||||||
|
<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.userId === user?.id ? "You (Organizer)" : "Organizer"}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-muted text-muted-foreground text-[10px] flex items-center justify-center font-bold ring-1 ring-border">
|
||||||
|
{detailEvent.creator?.name.charAt(0).toUpperCase() || "?"}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{detailEvent.creator?.name || "Unknown"}</span>
|
||||||
|
{detailEvent.creator?.role && (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.creator.role})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-primary/5 to-primary/[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.participantId === user?.id ? "You (Participant)" : "Participant"}
|
||||||
|
</p>
|
||||||
|
{detailEvent.participant ? (
|
||||||
|
<p className="text-sm font-bold flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-primary/10 text-primary text-[10px] flex items-center justify-center font-bold ring-1 ring-primary/20">
|
||||||
|
{detailEvent.participant.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{detailEvent.participant.name}</span>
|
||||||
|
{detailEvent.participant.role && (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.participant.role})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/50 italic">No participant</p>
|
||||||
|
)}
|
||||||
|
</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">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider flex items-center gap-1.5">
|
||||||
|
<StickyNote className="h-3 w-3" />
|
||||||
|
Notes
|
||||||
|
</p>
|
||||||
|
{!editNotes && (
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] font-semibold hover:bg-accent/40" onClick={() => { setNotesText(detailEvent.participantNotes || ""); setEditNotes(true) }}>
|
||||||
|
{detailEvent.participantNotes ? "Edit" : "Add note"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editNotes ? (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<Textarea value={notesText} onChange={(e) => setNotesText(e.target.value)} rows={3} className="resize-none text-sm" placeholder="Add your notes about this event…" />
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditNotes(false)} disabled={notesSaving}>Cancel</Button>
|
||||||
|
<Button size="sm" onClick={() => saveNotes(detailEvent)} disabled={notesSaving} className="shadow-sm">
|
||||||
|
{notesSaving && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : detailEvent.participantNotes ? (
|
||||||
|
<p className="text-sm whitespace-pre-wrap leading-relaxed font-medium">{detailEvent.participantNotes}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/40 italic font-medium">No notes yet</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||||
|
{detailEvent.status === "scheduled" && (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => { setDetailEvent(null); openEditDialog(detailEvent) }}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{detailEvent.status === "completed" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "scheduled")}>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1.5" /> Reopen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+551
-108
@@ -17,49 +17,51 @@ import {
|
|||||||
import {
|
import {
|
||||||
Search, Send, Phone, MoreHorizontal, Paperclip,
|
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
CornerDownRight, Forward, Pencil, Download,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
|
import { sanitizeSvg } from "@/lib/sanitize"
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
DialogFooter, DialogClose,
|
DialogFooter, DialogClose,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
import { useTheme } from "next-themes"
|
import { useTheme } from "next-themes"
|
||||||
import { useUser } from "@/providers/user-provider"
|
import { useUser } from "@/providers/user-provider"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { io, Socket } from "socket.io-client"
|
||||||
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
import VoiceCallModal from "@/components/chats/voice-call-modal"
|
||||||
import data from "@emoji-mart/data"
|
import MediaPicker from "@/components/chats/media-picker"
|
||||||
import Picker from "@emoji-mart/react"
|
|
||||||
|
|
||||||
function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDuration: number }) {
|
const activeAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||||
|
const previewAudioRef: { current: HTMLAudioElement | null } = { current: null }
|
||||||
|
const previewAnimRef: { current: number } = { current: 0 }
|
||||||
|
|
||||||
|
function VoiceMessagePlayer({ src, initialDuration, isOwn }: { src: string; initialDuration: number; isOwn?: boolean }) {
|
||||||
const [playing, setPlaying] = useState(false)
|
const [playing, setPlaying] = useState(false)
|
||||||
const [current, setCurrent] = useState(0)
|
const [current, setCurrent] = useState(0)
|
||||||
const [duration, setDuration] = useState(initialDuration)
|
const [duration, setDuration] = useState(initialDuration)
|
||||||
const audioRef = useRef<HTMLAudioElement>(null)
|
const audioRef = useRef<HTMLAudioElement>(null)
|
||||||
const animRef = useRef<number>(0)
|
const animRef = useRef<number>(0)
|
||||||
|
const progRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audio = audioRef.current
|
const audio = audioRef.current
|
||||||
if (!audio) return
|
if (!audio) return
|
||||||
const onLoaded = () => {
|
const onLoaded = () => { if (isFinite(audio.duration)) setDuration(audio.duration) }
|
||||||
if (isFinite(audio.duration)) setDuration(audio.duration)
|
|
||||||
}
|
|
||||||
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
const onEnded = () => { setPlaying(false); setCurrent(0) }
|
||||||
audio.addEventListener("loadedmetadata", onLoaded)
|
audio.addEventListener("loadedmetadata", onLoaded)
|
||||||
audio.addEventListener("ended", onEnded)
|
audio.addEventListener("ended", onEnded)
|
||||||
return () => {
|
return () => { audio.removeEventListener("loadedmetadata", onLoaded); audio.removeEventListener("ended", onEnded) }
|
||||||
audio.removeEventListener("loadedmetadata", onLoaded)
|
|
||||||
audio.removeEventListener("ended", onEnded)
|
|
||||||
}
|
|
||||||
}, [src])
|
}, [src])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
if (!playing) { cancelAnimationFrame(animRef.current); return }
|
||||||
const tick = () => {
|
const tick = () => { if (audioRef.current) setCurrent(audioRef.current.currentTime); animRef.current = requestAnimationFrame(tick) }
|
||||||
if (audioRef.current) setCurrent(audioRef.current.currentTime)
|
|
||||||
animRef.current = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
animRef.current = requestAnimationFrame(tick)
|
animRef.current = requestAnimationFrame(tick)
|
||||||
return () => cancelAnimationFrame(animRef.current)
|
return () => cancelAnimationFrame(animRef.current)
|
||||||
}, [playing])
|
}, [playing])
|
||||||
@@ -67,26 +69,55 @@ function VoiceMessagePlayer({ src, initialDuration }: { src: string; initialDura
|
|||||||
const toggle = () => {
|
const toggle = () => {
|
||||||
if (!audioRef.current) return
|
if (!audioRef.current) return
|
||||||
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
if (playing) { audioRef.current.pause(); setPlaying(false) }
|
||||||
else { audioRef.current.play(); setPlaying(true) }
|
else {
|
||||||
|
if (activeAudioRef.current && activeAudioRef.current !== audioRef.current) { activeAudioRef.current.pause() }
|
||||||
|
activeAudioRef.current = audioRef.current
|
||||||
|
audioRef.current.play(); setPlaying(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = parseFloat(e.target.value)
|
||||||
|
if (audioRef.current) { audioRef.current.currentTime = val; setCurrent(val) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const replay = () => {
|
||||||
|
if (!audioRef.current) return
|
||||||
|
audioRef.current.currentTime = 0; setCurrent(0)
|
||||||
|
if (!playing) { audioRef.current.play(); setPlaying(true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
const displayDuration = isFinite(duration) && duration > 0 ? duration : initialDuration
|
||||||
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
const pct = displayDuration > 0 ? (current / displayDuration) * 100 : 0
|
||||||
const fmt = (s: number) => {
|
const fmt = (s: number) => { const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0; return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}` }
|
||||||
const secs = isFinite(s) ? Math.max(0, Math.floor(s)) : 0
|
|
||||||
return `${Math.floor(secs / 60)}:${(secs % 60).toString().padStart(2, "0")}`
|
const barCount = 28
|
||||||
}
|
const waveform = Array.from({ length: barCount }, (_, i) => {
|
||||||
|
const peak = 0.15 + Math.sin(i * 1.1) * 0.35 + Math.sin(i * 2.3) * 0.2 + Math.sin(i * 0.7) * 0.3
|
||||||
|
return Math.max(0.15, Math.min(1, peak))
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 min-w-[180px]">
|
<div className="flex items-center gap-2 min-w-[200px] select-none">
|
||||||
<audio ref={audioRef} src={src} preload="metadata" />
|
<audio ref={audioRef} src={src} preload="metadata" />
|
||||||
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
<button type="button" onClick={toggle} className="h-8 w-8 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
||||||
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
{playing ? <Pause className="h-4 w-4 fill-current" /> : <Play className="h-4 w-4 ml-0.5 fill-current" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 h-1.5 rounded-full bg-white/30 relative overflow-hidden">
|
<div className="flex-1 flex flex-col gap-0.5 min-w-0">
|
||||||
<div className="h-full rounded-full bg-white transition-all duration-100" style={{ width: `${pct}%` }} />
|
<div className="flex items-center gap-1 h-8">
|
||||||
|
{waveform.map((h, i) => {
|
||||||
|
const filled = (i / barCount) <= (pct / 100)
|
||||||
|
return <div key={i} className="flex-1 rounded-full transition-colors duration-75" style={{ height: `${h * 100}%`, minHeight: 3, background: filled ? "currentColor" : "currentColor", opacity: filled ? 1 : 0.35 }} />
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<input type="range" ref={progRef} min={0} max={displayDuration || 1} step={0.1} value={Math.min(current, displayDuration)} onChange={seek} className="w-full h-1 cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-white/20 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-0" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||||
|
<span className="text-[11px] tabular-nums opacity-70">{fmt(current)}</span>
|
||||||
|
<button type="button" onClick={replay} className="opacity-50 hover:opacity-100 transition-opacity">
|
||||||
|
<Undo2 className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] tabular-nums opacity-70 w-8 text-right">{fmt(displayDuration)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -100,6 +131,7 @@ export default function ChatsPage() {
|
|||||||
const [activeChat, setActiveChat] = useState<string | null>(null)
|
const [activeChat, setActiveChat] = useState<string | null>(null)
|
||||||
const [messageInput, setMessageInput] = useState("")
|
const [messageInput, setMessageInput] = useState("")
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
|
const [showAttachMenu, setShowAttachMenu] = useState(false)
|
||||||
const [attachments, setAttachments] = useState<File[]>([])
|
const [attachments, setAttachments] = useState<File[]>([])
|
||||||
const [panelWidth, setPanelWidth] = useState(320)
|
const [panelWidth, setPanelWidth] = useState(320)
|
||||||
const [isResizing, setIsResizing] = useState(false)
|
const [isResizing, setIsResizing] = useState(false)
|
||||||
@@ -113,6 +145,11 @@ export default function ChatsPage() {
|
|||||||
const [isPaused, setIsPaused] = useState(false)
|
const [isPaused, setIsPaused] = useState(false)
|
||||||
const [recordingDuration, setRecordingDuration] = useState(0)
|
const [recordingDuration, setRecordingDuration] = useState(0)
|
||||||
const recordingDurationRef = useRef(0)
|
const recordingDurationRef = useRef(0)
|
||||||
|
const [previewBlob, setPreviewBlob] = useState<Blob | null>(null)
|
||||||
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||||
|
const [previewDuration, setPreviewDuration] = useState(0)
|
||||||
|
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||||
|
const [previewCurrent, setPreviewCurrent] = useState(0)
|
||||||
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
const [voiceMessages, setVoiceMessages] = useState<Map<string, { url: string; duration: number }>>(new Map())
|
||||||
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
const [messageAttachments, setMessageAttachments] = useState<Map<string, { name: string; type: string; url: string }[]>>(new Map())
|
||||||
const [replyingTo, setReplyingTo] = useState<any>(null)
|
const [replyingTo, setReplyingTo] = useState<any>(null)
|
||||||
@@ -125,20 +162,44 @@ export default function ChatsPage() {
|
|||||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||||
const [searchingUsers, setSearchingUsers] = useState(false)
|
const [searchingUsers, setSearchingUsers] = useState(false)
|
||||||
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
const [unreadMap, setUnreadMap] = useState<Map<string, number>>(new Map())
|
||||||
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
|
||||||
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
const [previewAvatarUrl, setPreviewAvatarUrl] = useState<string | null>(null)
|
||||||
|
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null)
|
||||||
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false)
|
||||||
|
const [scheduleTitle, setScheduleTitle] = useState("")
|
||||||
|
const [scheduleDate, setScheduleDate] = useState("")
|
||||||
|
const [scheduleTime, setScheduleTime] = useState("")
|
||||||
|
const [scheduleDuration, setScheduleDuration] = useState("30")
|
||||||
|
const [scheduleType, setScheduleType] = useState<string>("meeting")
|
||||||
|
const [scheduleSaving, setScheduleSaving] = useState(false)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const isSendingRef = useRef(false)
|
||||||
|
const attachMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||||
const recordingChunksRef = useRef<Blob[]>([])
|
const recordingChunksRef = useRef<Blob[]>([])
|
||||||
const resizeStartRef = useRef({ x: 0, width: 0 })
|
const resizeStartRef = useRef({ x: 0, width: 0 })
|
||||||
const blobUrlsRef = useRef<string[]>([])
|
const blobUrlsRef = useRef<string[]>([])
|
||||||
const conversationOrderRef = useRef<string[]>([])
|
const conversationOrderRef = useRef<string[]>([])
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const [isCallModalOpen, setIsCallModalOpen] = useState(false)
|
||||||
|
const socketRef = useRef<Socket | null>(null)
|
||||||
|
const isDeletingRef = useRef(false)
|
||||||
|
|
||||||
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
const isOnlyEmoji = (text: string) => /^(\p{Extended_Pictographic}\s*)+$/u.test(text.trim())
|
||||||
|
|
||||||
|
const formatPreviewContent = (content: string) => {
|
||||||
|
if (!content?.startsWith("{")) return content
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(content)
|
||||||
|
if (p.gif) return "📷 GIF"
|
||||||
|
if (p.v) return "🎤 Voice message"
|
||||||
|
if (p.sticker) return "💮 Sticker"
|
||||||
|
} catch {}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
const autoResizeTextarea = useCallback(() => {
|
const autoResizeTextarea = useCallback(() => {
|
||||||
const ta = textareaRef.current
|
const ta = textareaRef.current
|
||||||
if (!ta) return
|
if (!ta) return
|
||||||
@@ -208,6 +269,17 @@ export default function ChatsPage() {
|
|||||||
setMessageAttachments((a) => {
|
setMessageAttachments((a) => {
|
||||||
const n = new Map(a)
|
const n = new Map(a)
|
||||||
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
for (const k of n.keys()) if (!validMsgIds.has(k)) n.delete(k)
|
||||||
|
// Restore persisted file attachments from JSON content
|
||||||
|
for (const msgs of next.values()) {
|
||||||
|
for (const m of msgs) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(m.content)
|
||||||
|
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||||
|
n.set(m.id, parsed.fileAttachments)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
return n
|
return n
|
||||||
})
|
})
|
||||||
setReplyMap((r) => {
|
setReplyMap((r) => {
|
||||||
@@ -257,6 +329,26 @@ export default function ChatsPage() {
|
|||||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Strip blocked files from attachments on every change
|
||||||
|
useEffect(() => {
|
||||||
|
if (attachments.length === 0) return
|
||||||
|
const { allowed, rejected } = filterBlockedFiles(attachments)
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
toast.error(`Blocked file type: ${rejected[0].name}`)
|
||||||
|
setAttachments(allowed)
|
||||||
|
}
|
||||||
|
}, [attachments])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (attachMenuRef.current && !attachMenuRef.current.contains(e.target as Node)) {
|
||||||
|
setShowAttachMenu(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
// revoke blob URLs on unmount
|
// revoke blob URLs on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -265,6 +357,65 @@ export default function ChatsPage() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Poll for new messages every 4 seconds for real-time sync
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChat) return
|
||||||
|
pollTimerRef.current = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/conversations/${activeChat}/messages?limit=50`)
|
||||||
|
if (!res.ok) return
|
||||||
|
const data = await res.json()
|
||||||
|
const serverMsgs = data.messages || []
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const existing = prev.get(activeChat) || []
|
||||||
|
if (serverMsgs.length === existing.length) return prev
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(activeChat, serverMsgs)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setConversations((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const convIdx = updated.findIndex((c) => c.id === activeChat)
|
||||||
|
if (convIdx >= 0 && serverMsgs.length > 0) {
|
||||||
|
const last = serverMsgs[serverMsgs.length - 1]
|
||||||
|
updated[convIdx] = { ...updated[convIdx], lastMessage: last.content, lastMessageTime: last.timestamp }
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
} catch { /* polling silently */ }
|
||||||
|
}, 4000)
|
||||||
|
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current) }
|
||||||
|
}, [activeChat])
|
||||||
|
|
||||||
|
// Connect to signaling server for real-time message deletion events
|
||||||
|
useEffect(() => {
|
||||||
|
let socket: Socket | null = null
|
||||||
|
let cancelled = false
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/jwt")
|
||||||
|
if (!res.ok) return
|
||||||
|
const { token } = await res.json()
|
||||||
|
if (cancelled) return
|
||||||
|
socket = io(process.env.NEXT_PUBLIC_SIGNALING_URL || "http://localhost:3007", {
|
||||||
|
auth: { token },
|
||||||
|
transports: ["websocket", "polling"],
|
||||||
|
})
|
||||||
|
socketRef.current = socket
|
||||||
|
socket.on("message:deleted", ({ conversationId, messageId }: { conversationId: string; messageId: string }) => {
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const existing = prev.get(conversationId)
|
||||||
|
if (!existing) return prev
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(conversationId, existing.filter((m) => m.id !== messageId))
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch { /* socket connection failed silently */ }
|
||||||
|
})()
|
||||||
|
return () => { cancelled = true; socket?.disconnect(); socketRef.current = null }
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsResizing(true)
|
setIsResizing(true)
|
||||||
@@ -291,23 +442,32 @@ export default function ChatsPage() {
|
|||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [conversations])
|
}, [conversations])
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji: { native: string }) => {
|
const handleEmojiSelect = (emoji: string) => {
|
||||||
setMessageInput((prev) => prev + emoji.native)
|
setMessageInput((prev) => prev + emoji)
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMediaSelect = (content: string) => {
|
||||||
|
addMessageToChat(content)
|
||||||
|
setShowEmojiPicker(false)
|
||||||
|
}
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10MB
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files ?? [])
|
const raw = Array.from(e.target.files ?? [])
|
||||||
const oversized = files.filter((f) => f.size > MAX_FILE_SIZE)
|
const oversized = raw.filter((f) => f.size > MAX_FILE_SIZE)
|
||||||
if (oversized.length > 0) {
|
if (oversized.length > 0) {
|
||||||
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
toast.error(`${oversized[0].name} exceeds the 10MB file size limit`)
|
||||||
if (e.target) e.target.value = ""
|
if (e.target) e.target.value = ""
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setAttachments((prev) => [...prev, ...files])
|
const { allowed, rejected } = filterBlockedFiles(raw)
|
||||||
|
if (rejected.length > 0) {
|
||||||
|
toast.error(`Blocked file type: ${rejected[0].name}`)
|
||||||
|
}
|
||||||
|
setAttachments((prev) => [...prev, ...allowed])
|
||||||
if (e.target) e.target.value = ""
|
if (e.target) e.target.value = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,7 +477,11 @@ export default function ChatsPage() {
|
|||||||
|
|
||||||
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
const addMessageToChat = async (content: string, voice?: { url: string; duration: number }, replyTo?: { senderName: string; content: string }, fileAttachments?: { name: string; type: string; url: string }[]) => {
|
||||||
if (!activeChat || !user) return
|
if (!activeChat || !user) return
|
||||||
const payload = voice ? "[Voice message]" : content
|
const payload = voice
|
||||||
|
? "[Voice message]"
|
||||||
|
: fileAttachments && fileAttachments.length > 0
|
||||||
|
? JSON.stringify({ text: content, fileAttachments })
|
||||||
|
: content
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
const res = await fetch(`/api/conversations/${activeChat}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -336,7 +500,7 @@ export default function ChatsPage() {
|
|||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const updated = prev.map((conv) =>
|
const updated = prev.map((conv) =>
|
||||||
conv.id === activeChat
|
conv.id === activeChat
|
||||||
? { ...conv, lastMessage: content, lastMessageTime: "Just now" }
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||||
@@ -378,73 +542,114 @@ export default function ChatsPage() {
|
|||||||
const handleSend = async (e: React.FormEvent) => {
|
const handleSend = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!messageInput.trim() && attachments.length === 0) return
|
if (!messageInput.trim() && attachments.length === 0) return
|
||||||
if (editingMessage) {
|
if (isSendingRef.current) return
|
||||||
|
// Check blocked files before sending
|
||||||
|
if (attachments.some((f) => hasBlockedCodeExtension(f.name))) {
|
||||||
|
toast.error("Remove blocked file types before sending")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSendingRef.current = true
|
||||||
|
try {
|
||||||
|
if (editingMessage) {
|
||||||
|
setConversationMessages((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
||||||
|
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
||||||
|
)
|
||||||
|
next.set(activeChat || "", msgs)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
setEditingMessage(null)
|
||||||
|
setMessageInput("")
|
||||||
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fileAttachments = attachments.length > 0
|
||||||
|
? await Promise.all(attachments.map(async (f) => {
|
||||||
|
const url = await fileToDataURL(f)
|
||||||
|
return { name: f.name, type: f.type, url }
|
||||||
|
}))
|
||||||
|
: undefined
|
||||||
|
if (replyingTo) {
|
||||||
|
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
||||||
|
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
||||||
|
setReplyingTo(null)
|
||||||
|
} else {
|
||||||
|
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
||||||
|
}
|
||||||
|
setMessageInput("")
|
||||||
|
setTimeout(() => autoResizeTextarea(), 0)
|
||||||
|
setAttachments([])
|
||||||
|
} finally {
|
||||||
|
isSendingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteMessage = async (messageId: string) => {
|
||||||
|
if (!activeChat || !user) return
|
||||||
|
if (isDeletingRef.current) return
|
||||||
|
isDeletingRef.current = true
|
||||||
|
|
||||||
|
const prevMessages = conversationMessages.get(activeChat) || []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/conversations/${activeChat}/messages/${messageId}`, { method: "DELETE" })
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(err.error || "Delete failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast deletion to other participants via Socket.io
|
||||||
|
const socket = socketRef.current
|
||||||
|
if (socket?.connected) {
|
||||||
|
socket.emit("message:deleted", { conversationId: activeChat, messageId, senderId: user.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revoke blob URLs for attachments and voice
|
||||||
|
const files = messageAttachments.get(messageId)
|
||||||
|
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
||||||
|
const voice = voiceMessages.get(messageId)
|
||||||
|
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
||||||
|
|
||||||
|
// Remove from local state
|
||||||
setConversationMessages((prev) => {
|
setConversationMessages((prev) => {
|
||||||
const next = new Map(prev)
|
const next = new Map(prev)
|
||||||
const msgs = (next.get(activeChat || "") || []).map((m) =>
|
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
||||||
m.id === editingMessage.id ? { ...m, content: messageInput.trim() } : m
|
|
||||||
)
|
|
||||||
next.set(activeChat || "", msgs)
|
next.set(activeChat || "", msgs)
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
setEditingMessage(null)
|
setConversations((prev) =>
|
||||||
setMessageInput("")
|
prev.map((conv) =>
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
conv.id === activeChat
|
||||||
return
|
? { ...conv, lastMessage: getDisplayContent(prevMessages.filter((m) => m.id !== messageId).at(-1)?.content ?? "") }
|
||||||
}
|
: conv
|
||||||
const fileAttachments = attachments.length > 0
|
)
|
||||||
? attachments.map((f) => {
|
|
||||||
const url = URL.createObjectURL(f)
|
|
||||||
blobUrlsRef.current.push(url)
|
|
||||||
return { name: f.name, type: f.type, url }
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
if (replyingTo) {
|
|
||||||
const replySender = replyingTo.senderId === user.id ? user.name : otherParticipant(conversation!).name
|
|
||||||
await addMessageToChat(messageInput.trim(), undefined, { senderName: replySender, content: replyingTo.content }, fileAttachments)
|
|
||||||
setReplyingTo(null)
|
|
||||||
} else {
|
|
||||||
await addMessageToChat(messageInput.trim(), undefined, undefined, fileAttachments)
|
|
||||||
}
|
|
||||||
setMessageInput("")
|
|
||||||
setTimeout(() => autoResizeTextarea(), 0)
|
|
||||||
setAttachments([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteMessage = (messageId: string) => {
|
|
||||||
const files = messageAttachments.get(messageId)
|
|
||||||
if (files) files.forEach((f) => { URL.revokeObjectURL(f.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== f.url) })
|
|
||||||
const voice = voiceMessages.get(messageId)
|
|
||||||
if (voice) { URL.revokeObjectURL(voice.url); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== voice.url) }
|
|
||||||
setConversationMessages((prev) => {
|
|
||||||
const next = new Map(prev)
|
|
||||||
const msgs = (next.get(activeChat || "") || []).filter((m) => m.id !== messageId)
|
|
||||||
next.set(activeChat || "", msgs)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setConversations((prev) =>
|
|
||||||
prev.map((conv) =>
|
|
||||||
conv.id === activeChat
|
|
||||||
? { ...conv, lastMessage: conversationMessages.get(activeChat || "")?.filter((m) => m.id !== messageId).at(-1)?.content ?? conv.lastMessage }
|
|
||||||
: conv
|
|
||||||
)
|
)
|
||||||
)
|
toast.success("Message deleted")
|
||||||
toast.success("Message deleted")
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || "Unable to delete voice note. Please try again.")
|
||||||
|
} finally {
|
||||||
|
isDeletingRef.current = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" })
|
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm"
|
||||||
|
const recorder = new MediaRecorder(stream, { mimeType: mime })
|
||||||
recordingChunksRef.current = []
|
recordingChunksRef.current = []
|
||||||
recorder.ondataavailable = (e) => {
|
recorder.ondataavailable = (e) => {
|
||||||
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
if (e.data.size > 0) recordingChunksRef.current.push(e.data)
|
||||||
}
|
}
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
const blob = new Blob(recordingChunksRef.current, { type: "audio/webm" })
|
if (recordingChunksRef.current.length === 0) return
|
||||||
|
const blob = new Blob(recordingChunksRef.current, { type: mime })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
blobUrlsRef.current.push(url)
|
blobUrlsRef.current.push(url)
|
||||||
addMessageToChat("", { url, duration: recordingDurationRef.current })
|
setPreviewBlob(blob)
|
||||||
|
setPreviewUrl(url)
|
||||||
|
setPreviewDuration(recordingDurationRef.current)
|
||||||
stream.getTracks().forEach((t) => t.stop())
|
stream.getTracks().forEach((t) => t.stop())
|
||||||
setRecordingDuration(0)
|
setRecordingDuration(0)
|
||||||
}
|
}
|
||||||
@@ -452,9 +657,12 @@ export default function ChatsPage() {
|
|||||||
recorder.start()
|
recorder.start()
|
||||||
setIsRecording(true)
|
setIsRecording(true)
|
||||||
setIsPaused(false)
|
setIsPaused(false)
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
console.warn("Failed to start recording in chats page")
|
if (err?.name === "NotAllowedError" || err?.name === "PermissionDeniedError") {
|
||||||
toast.error("Microphone access denied")
|
toast.error("Microphone access is required to record voice notes.")
|
||||||
|
} else {
|
||||||
|
toast.error("Failed to start recording. Please check your microphone.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,6 +696,60 @@ export default function ChatsPage() {
|
|||||||
setRecordingDuration(0)
|
setRecordingDuration(0)
|
||||||
recordingDurationRef.current = 0
|
recordingDurationRef.current = 0
|
||||||
recordingChunksRef.current = []
|
recordingChunksRef.current = []
|
||||||
|
clearPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
if (previewUrl) { URL.revokeObjectURL(previewUrl); blobUrlsRef.current = blobUrlsRef.current.filter((u) => u !== previewUrl) }
|
||||||
|
setPreviewBlob(null)
|
||||||
|
setPreviewUrl(null)
|
||||||
|
setPreviewDuration(0)
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
setPreviewCurrent(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelVoicePreview = () => {
|
||||||
|
if (previewAudioRef.current) { previewAudioRef.current.pause(); previewAudioRef.current = null! }
|
||||||
|
cancelAnimationFrame(previewAnimRef.current)
|
||||||
|
clearPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePreviewPlay = () => {
|
||||||
|
if (!previewUrl) return
|
||||||
|
if (isPreviewPlaying) {
|
||||||
|
previewAudioRef.current?.pause()
|
||||||
|
setIsPreviewPlaying(false)
|
||||||
|
} else {
|
||||||
|
const audio = new Audio(previewUrl)
|
||||||
|
audio.onloadedmetadata = () => { if (isFinite(audio.duration)) setPreviewDuration(audio.duration) }
|
||||||
|
audio.onended = () => { setIsPreviewPlaying(false); setPreviewCurrent(0) }
|
||||||
|
audio.onerror = () => { toast.error("Failed to play preview"); setIsPreviewPlaying(false) }
|
||||||
|
previewAudioRef.current = audio
|
||||||
|
audio.play().then(() => setIsPreviewPlaying(true)).catch(() => toast.error("Failed to play preview"))
|
||||||
|
const tick = () => { if (previewAudioRef.current) setPreviewCurrent(previewAudioRef.current.currentTime); previewAnimRef.current = requestAnimationFrame(tick) }
|
||||||
|
cancelAnimationFrame(previewAnimRef.current)
|
||||||
|
previewAnimRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendVoiceRecording = async () => {
|
||||||
|
if (!previewBlob || !previewUrl) return
|
||||||
|
try {
|
||||||
|
cancelAnimationFrame(previewAnimRef.current)
|
||||||
|
previewAudioRef.current?.pause()
|
||||||
|
const formData = new FormData()
|
||||||
|
const ext = previewBlob.type.includes("webm") ? "webm" : "webm"
|
||||||
|
formData.append("audio", previewBlob, `voice.${ext}`)
|
||||||
|
formData.append("duration", previewDuration.toString())
|
||||||
|
const uploadRes = await fetch("/api/upload/voice", { method: "POST", body: formData })
|
||||||
|
if (!uploadRes.ok) { toast.error("Failed to upload voice recording"); return }
|
||||||
|
const { url: serverUrl, duration } = await uploadRes.json()
|
||||||
|
const voicePayload = JSON.stringify({ v: true, u: serverUrl, d: duration || previewDuration })
|
||||||
|
await addMessageToChat(voicePayload, { url: serverUrl, duration: duration || previewDuration })
|
||||||
|
clearPreview()
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to send voice recording")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -515,8 +777,23 @@ export default function ChatsPage() {
|
|||||||
|
|
||||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
||||||
|
|
||||||
|
const getDisplayContent = (content: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.text !== undefined) return parsed.text
|
||||||
|
} catch {}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileToDataURL = (file: File): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
{/* Conversations list - left panel */}
|
{/* Conversations list - left panel */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-r shrink-0 overflow-hidden"
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
||||||
@@ -564,7 +841,7 @@ export default function ChatsPage() {
|
|||||||
<span className="text-sm font-medium truncate">{person.name}</span>
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
||||||
</div>
|
</div>
|
||||||
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
{!isActive && (unreadMap.get(conv.id) || 0) > 0 && (
|
||||||
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
<div className="shrink-0 h-3 w-3 rounded-full bg-red-500 animate-pulse shadow-[0_0_10px_#ef4444] self-center" />
|
||||||
@@ -647,6 +924,9 @@ export default function ChatsPage() {
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
|
||||||
|
<CalendarDays className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
@@ -676,7 +956,17 @@ export default function ChatsPage() {
|
|||||||
<div className="space-y-4 min-h-0">
|
<div className="space-y-4 min-h-0">
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
const isMe = msg.senderId === user?.id
|
const isMe = msg.senderId === user?.id
|
||||||
const voiceUrl = voiceMessages.get(msg.id)
|
let voiceUrl = voiceMessages.get(msg.id)
|
||||||
|
let gifData: { url: string; w: number; h: number } | null = null
|
||||||
|
let stickerData: { url: string; w: number; h: number; emoji?: string } | null = null
|
||||||
|
if (msg.content?.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
const p = JSON.parse(msg.content)
|
||||||
|
if (p.v && p.u) voiceUrl = { url: p.u, duration: p.d || 0 }
|
||||||
|
else if (p.gif) gifData = { url: p.gif, w: p.w || 320, h: p.h || 240 }
|
||||||
|
else if (p.sticker) stickerData = { url: p.sticker, w: p.w || 200, h: p.h || 200, emoji: p.id ? undefined : p.sticker }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
<div key={msg.id} className={cn("group flex gap-3", isMe && "flex-row-reverse")}>
|
||||||
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
<Avatar className="h-8 w-8 mt-0.5 shrink-0 cursor-pointer" onClick={() => setPreviewAvatarUrl(msg.senderAvatar)}>
|
||||||
@@ -743,13 +1033,22 @@ export default function ChatsPage() {
|
|||||||
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
<p className="text-[11px] text-white/60 truncate">{replyInfo.repliedToContent}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{voiceUrl ? (
|
{gifData ? (
|
||||||
<div>
|
<div className="max-w-[280px]">
|
||||||
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} />
|
<img src={gifData.url} alt="GIF" className="w-full rounded-lg" style={{ aspectRatio: `${gifData.w}/${gifData.h}` }} loading="lazy" />
|
||||||
{msg.content && (
|
</div>
|
||||||
<p className="mt-1 text-sm text-black">{msg.content}</p>
|
) : stickerData ? (
|
||||||
|
<div className="max-w-[200px]">
|
||||||
|
{stickerData.emoji ? (
|
||||||
|
<span className="text-7xl block text-center">{stickerData.emoji}</span>
|
||||||
|
) : (
|
||||||
|
<div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : voiceUrl ? (
|
||||||
|
<div>
|
||||||
|
<VoiceMessagePlayer src={voiceUrl.url} initialDuration={voiceUrl.duration} isOwn={isMe} />
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -759,26 +1058,30 @@ export default function ChatsPage() {
|
|||||||
{files.map((file, i) =>
|
{files.map((file, i) =>
|
||||||
file.type.startsWith("image/") ? (
|
file.type.startsWith("image/") ? (
|
||||||
<div key={i} className="relative group">
|
<div key={i} className="relative group">
|
||||||
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => window.open(file.url, "_blank")} />
|
<img src={file.url} alt={file.name} className="max-w-full rounded-lg max-h-60 object-cover cursor-pointer" onClick={() => setPreviewImageUrl(file.url)} />
|
||||||
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
<a href={file.url} download={file.name} className="absolute top-2 right-2 h-7 w-7 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<Download className="h-3.5 w-3.5 text-white" />
|
<Download className="h-3.5 w-3.5 text-white" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<a key={i} href={file.url} download={file.name} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
<div key={i} className="flex items-center gap-2 rounded-lg border bg-muted/50 px-3 py-2 text-sm hover:bg-muted/80 transition-colors">
|
||||||
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<button type="button" className="flex items-center gap-2 flex-1 min-w-0 text-left" onClick={() => window.open(file.url, "_blank")} title="Open">
|
||||||
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
<FileIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<Download className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<span className="flex-1 min-w-0 truncate">{file.name}</span>
|
||||||
</a>
|
</button>
|
||||||
|
<a href={file.url} download={file.name} className="shrink-0 text-muted-foreground hover:text-foreground" title="Download">
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
})()}
|
})()}
|
||||||
{isOnlyEmoji(msg.content) ? (
|
{isOnlyEmoji(getDisplayContent(msg.content)) ? (
|
||||||
<span className="text-4xl leading-none">{msg.content.trim()}</span>
|
<span className="text-4xl leading-none">{getDisplayContent(msg.content).trim()}</span>
|
||||||
) : (
|
) : (
|
||||||
msg.content
|
getDisplayContent(msg.content)
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -847,9 +1150,27 @@ export default function ChatsPage() {
|
|||||||
)}
|
)}
|
||||||
<form onSubmit={handleSend} className="flex items-center gap-2">
|
<form onSubmit={handleSend} className="flex items-center gap-2">
|
||||||
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
|
<input ref={fileInputRef} type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt" className="hidden" onChange={handleFileSelect} />
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => fileInputRef.current?.click()}>
|
<div className="relative">
|
||||||
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
<Button type="button" variant="ghost" size="icon" className="h-9 w-9 shrink-0" onClick={() => setShowAttachMenu(!showAttachMenu)}>
|
||||||
</Button>
|
<Paperclip className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
{showAttachMenu && (
|
||||||
|
<div ref={attachMenuRef} className="absolute bottom-full left-0 mb-2 z-50 bg-popover border rounded-lg shadow-lg p-1.5 min-w-[140px]">
|
||||||
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", "image/*"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||||
|
<Image className="h-4 w-4" /> Photos
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".pdf,.doc,.docx,.xls,.xlsx,.txt"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||||
|
<FileIcon className="h-4 w-4" /> Documents
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.removeAttribute("accept"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||||
|
<FolderOpen className="h-4 w-4" /> Files
|
||||||
|
</button>
|
||||||
|
<button type="button" className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded-md hover:bg-muted" onClick={() => { fileInputRef.current?.setAttribute("accept", ".eml,.msg"); fileInputRef.current?.click(); setShowAttachMenu(false) }}>
|
||||||
|
<Mail className="h-4 w-4" /> Emails
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
{isRecording ? (
|
{isRecording ? (
|
||||||
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
<div className="flex h-9 items-center gap-1 rounded-md border bg-muted/30 px-2">
|
||||||
@@ -866,9 +1187,25 @@ export default function ChatsPage() {
|
|||||||
<Square className="h-3.5 w-3.5 fill-current" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : previewUrl ? (
|
||||||
|
<div className="flex h-9 items-center gap-2 rounded-md border bg-muted/30 px-3">
|
||||||
|
<button type="button" onClick={togglePreviewPlay} className="h-6 w-6 shrink-0 rounded-full flex items-center justify-center hover:bg-black/10 transition-colors">
|
||||||
|
{isPreviewPlaying ? <Pause className="h-3.5 w-3.5 fill-current" /> : <Play className="h-3.5 w-3.5 ml-0.5 fill-current" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 h-1 rounded-full bg-muted-foreground/20 relative overflow-hidden">
|
||||||
|
<div className="h-full rounded-full bg-primary transition-all duration-100" style={{ width: `${previewDuration > 0 ? (previewCurrent / previewDuration) * 100 : 0}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs tabular-nums text-muted-foreground w-8 text-right">{formatDuration(Math.floor(previewCurrent))}/{formatDuration(previewDuration)}</span>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-destructive hover:text-destructive" onClick={cancelVoicePreview}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="icon" className="h-6 w-6 shrink-0 text-primary hover:text-primary" onClick={sendVoiceRecording}>
|
||||||
|
<Send className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...files]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
<textarea ref={textareaRef} value={messageInput} onChange={(e) => { setMessageInput(e.target.value); autoResizeTextarea() }} onPaste={(e) => { if (!e.clipboardData) return; const items = Array.from(e.clipboardData.items).filter((item) => item.type.startsWith("image/")); if (items.length > 0) { e.preventDefault(); const files = items.map((item) => item.getAsFile()).filter((f): f is File => f !== null); setAttachments((prev) => [...prev, ...filterBlockedFiles(files).allowed]) } }} placeholder={editingMessage ? "Edit message..." : "Type a message..."} className="w-full resize-none overflow-hidden rounded-md border border-input bg-transparent px-3 py-1.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 min-h-[36px] max-h-[200px] pr-16" style={{ height: "auto" }} rows={1} />
|
||||||
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
<div className="absolute right-0 top-0 bottom-0 flex items-center pr-1" ref={emojiPickerRef}>
|
||||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setShowEmojiPicker(!showEmojiPicker)}>
|
||||||
<Smile className="h-4 w-4 text-muted-foreground" />
|
<Smile className="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -878,14 +1215,14 @@ export default function ChatsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
<div className="absolute bottom-full right-0 mb-2 z-50">
|
<div className="absolute bottom-full right-0 mb-2 z-50">
|
||||||
<Picker data={data} onEmojiSelect={handleEmojiSelect} theme={theme === "dark" ? "dark" : "light"} previewPosition="none" skinTonePosition="none" set="native" emojiSize={36} maxFrequentRows={2} />
|
<MediaPicker onEmojiSelect={handleEmojiSelect} onMediaSelect={handleMediaSelect} onClose={() => setShowEmojiPicker(false)} theme={theme} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={!messageInput.trim() && attachments.length === 0}>
|
<Button type="submit" size="icon" className="h-9 w-9 shrink-0" disabled={(!messageInput.trim() && attachments.length === 0) || attachments.some(f => hasBlockedCodeExtension(f.name))}>
|
||||||
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
{editingMessage ? <Pencil className="h-4 w-4" /> : <Send className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
@@ -918,6 +1255,20 @@ export default function ChatsPage() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Image preview dialog */}
|
||||||
|
<Dialog open={!!previewImageUrl} onOpenChange={(o) => { if (!o) setPreviewImageUrl(null) }}>
|
||||||
|
<DialogContent className="sm:max-w-3xl p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||||
|
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
||||||
|
{previewImageUrl && (
|
||||||
|
<img
|
||||||
|
src={previewImageUrl}
|
||||||
|
alt="Image preview"
|
||||||
|
className="w-full h-auto max-h-[85vh] object-contain rounded-2xl"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Avatar preview dialog */}
|
{/* Avatar preview dialog */}
|
||||||
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
<Dialog open={!!previewAvatarUrl} onOpenChange={(o) => { if (!o) setPreviewAvatarUrl(null) }}>
|
||||||
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
<DialogContent className="sm:max-w-sm p-0 overflow-hidden bg-transparent border-0 shadow-none">
|
||||||
@@ -994,8 +1345,100 @@ export default function ChatsPage() {
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
<VoiceCallModal open={isCallModalOpen} onClose={() => setIsCallModalOpen(false)} />
|
||||||
|
{/* Schedule from Chat Dialog */}
|
||||||
|
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[440px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Schedule with {conversation ? otherParticipant(conversation).name : "..."}</DialogTitle>
|
||||||
|
<DialogDescription>Create an event and notify the participant</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sched-title">Title</Label>
|
||||||
|
<Input id="sched-title" value={scheduleTitle} onChange={(e) => setScheduleTitle(e.target.value)} placeholder="e.g. Follow-up call" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sched-date">Date</Label>
|
||||||
|
<Input id="sched-date" type="date" value={scheduleDate} onChange={(e) => setScheduleDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sched-time">Time</Label>
|
||||||
|
<Input id="sched-time" type="time" value={scheduleTime} onChange={(e) => setScheduleTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sched-type">Type</Label>
|
||||||
|
<Select value={scheduleType} onValueChange={setScheduleType}>
|
||||||
|
<SelectTrigger id="sched-type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="meeting">Meeting</SelectItem>
|
||||||
|
<SelectItem value="call">Call</SelectItem>
|
||||||
|
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||||
|
<SelectItem value="demo">Demo</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="sched-duration">Duration</Label>
|
||||||
|
<Select value={scheduleDuration} onValueChange={setScheduleDuration}>
|
||||||
|
<SelectTrigger id="sched-duration">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="15">15 min</SelectItem>
|
||||||
|
<SelectItem value="30">30 min</SelectItem>
|
||||||
|
<SelectItem value="60">1 hour</SelectItem>
|
||||||
|
<SelectItem value="90">1.5 hours</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setScheduleDialogOpen(false)}>Cancel</Button>
|
||||||
|
<Button disabled={scheduleSaving || !scheduleTitle || !scheduleDate || !scheduleTime} onClick={async () => {
|
||||||
|
if (!conversation || !user) return
|
||||||
|
setScheduleSaving(true)
|
||||||
|
try {
|
||||||
|
const startTime = new Date(`${scheduleDate}T${scheduleTime}:00`).toISOString()
|
||||||
|
const end = new Date(startTime)
|
||||||
|
end.setMinutes(end.getMinutes() + parseInt(scheduleDuration))
|
||||||
|
const res = await fetch("/api/events", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: scheduleTitle,
|
||||||
|
eventType: scheduleType,
|
||||||
|
startTime,
|
||||||
|
endTime: end.toISOString(),
|
||||||
|
durationMinutes: parseInt(scheduleDuration),
|
||||||
|
participantId: otherParticipant(conversation).id,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error("Failed")
|
||||||
|
toast.success("Event scheduled! Notification sent.")
|
||||||
|
setScheduleDialogOpen(false)
|
||||||
|
setScheduleTitle("")
|
||||||
|
setScheduleDate("")
|
||||||
|
setScheduleTime("")
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to schedule event")
|
||||||
|
} finally {
|
||||||
|
setScheduleSaving(false)
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{scheduleSaving && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Schedule
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
@@ -63,47 +65,54 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
{/* Daily Bugle watermark */}
|
<div className="relative z-[1] space-y-6">
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
<div>
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#e62020] dark:text-[#FF6666] tracking-wider">
|
<PageHeader
|
||||||
DAILY BUGLE
|
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
|
||||||
</span>
|
description="Overview of your sales pipeline"
|
||||||
|
>
|
||||||
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="7days">Last 7 days</SelectItem>
|
||||||
|
<SelectItem value="30days">Last 30 days</SelectItem>
|
||||||
|
<SelectItem value="6months">Last 6 months</SelectItem>
|
||||||
|
<SelectItem value="12months">Last 12 months</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</PageHeader>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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
|
||||||
|
? statCards.map((card, i) => (
|
||||||
|
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
||||||
|
))
|
||||||
|
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
|
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
{websiteTheme === "spidey" && (
|
||||||
<PageHeader title={<span style={{fontFamily:"'Bangers',cursive"}}>Dashboard</span>} description="Overview of your sales pipeline">
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
DAILY BUGLE
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
</span>
|
||||||
<SelectValue />
|
</div>
|
||||||
</SelectTrigger>
|
)}
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="7days">Last 7 days</SelectItem>
|
|
||||||
<SelectItem value="30days">Last 30 days</SelectItem>
|
|
||||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
|
||||||
<SelectItem value="12months">Last 12 months</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</PageHeader>
|
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
|
||||||
{stats
|
|
||||||
? statCards.map((card, i) => (
|
|
||||||
<StatCard key={card.title} {...card} index={i} monthlyBreakdown={stats.monthlyBreakdown} />
|
|
||||||
))
|
|
||||||
: Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
|
||||||
<LeadsPerMonthChart data={stats?.leadsPerMonth ?? []} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useUser } from "@/providers/user-provider"
|
||||||
|
import { Mail, Clock } from "lucide-react"
|
||||||
|
|
||||||
|
export default function EmailsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { user } = useUser()
|
||||||
|
const [emails, setEmails] = useState<any[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) { router.push("/login"); return }
|
||||||
|
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
|
||||||
|
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
|
||||||
|
}, [user, router])
|
||||||
|
|
||||||
|
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-xl font-bold mb-4">Email Log</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-6">
|
||||||
|
Emails are stored locally. To actually deliver them, add <code className="bg-muted px-1 rounded">EMAIL_API_KEY</code> to your .env.
|
||||||
|
</p>
|
||||||
|
{emails.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No emails sent yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{emails.map((e: any) => {
|
||||||
|
const withMatch = e.bodyText?.match(/With:\s*(.+)/)
|
||||||
|
const withName = withMatch ? withMatch[1].trim() : null
|
||||||
|
return (
|
||||||
|
<div key={e.id} className="flex items-center gap-3 p-3 rounded-lg border bg-card">
|
||||||
|
<Mail className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{e.subject}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">To: {e.recipient}</p>
|
||||||
|
{withName && <p className="text-xs text-muted-foreground/70 mt-0.5">With: {withName}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-muted-foreground shrink-0">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{new Date(e.createdAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { AppShell } from "@/components/layout/app-shell"
|
import { AppShell } from "@/components/layout/app-shell"
|
||||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||||
import { NotificationProvider } from "@/providers/notification-provider"
|
import { NotificationProvider } from "@/providers/notification-provider"
|
||||||
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
|
|||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useUser()
|
const { user, loading } = useUser()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
if (loading) {
|
useEffect(() => {
|
||||||
|
if (!user && !loading) {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}, [user, loading, router])
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
return <AppShell>{children}</AppShell>
|
return <AppShell>{children}</AppShell>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
comparePassword,
|
comparePassword,
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
@@ -10,8 +10,17 @@ import {
|
|||||||
isAccountLocked,
|
isAccountLocked,
|
||||||
createSession,
|
createSession,
|
||||||
setSessionContext,
|
setSessionContext,
|
||||||
|
SESSION_COOKIE,
|
||||||
} from "@/lib/auth"
|
} 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { email, username, password } = await request.json()
|
const { email, username, password } = await request.json()
|
||||||
@@ -19,16 +28,16 @@ export async function POST(request: NextRequest) {
|
|||||||
const credential = email || username
|
const credential = email || username
|
||||||
|
|
||||||
if (!credential || !password) {
|
if (!credential || !password) {
|
||||||
return NextResponse.json(
|
return jsonResponse(
|
||||||
{ error: "Email/Username and password are required." },
|
{ error: "Email/Username and password are required." },
|
||||||
{ status: 400 }
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (credential.trim().length === 0 || password.trim().length === 0) {
|
if (credential.trim().length === 0 || password.trim().length === 0) {
|
||||||
return NextResponse.json(
|
return jsonResponse(
|
||||||
{ error: "Credentials cannot be empty." },
|
{ error: "Credentials cannot be empty." },
|
||||||
{ status: 400 }
|
400
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,9 +67,9 @@ export async function POST(request: NextRequest) {
|
|||||||
false,
|
false,
|
||||||
"User not found"
|
"User not found"
|
||||||
)
|
)
|
||||||
return NextResponse.json(
|
return jsonResponse(
|
||||||
{ error: "Invalid email/username or password." },
|
{ error: "Invalid email/username or password." },
|
||||||
{ status: 401 }
|
401
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,9 +83,9 @@ export async function POST(request: NextRequest) {
|
|||||||
false,
|
false,
|
||||||
lockStatus.reason
|
lockStatus.reason
|
||||||
)
|
)
|
||||||
return NextResponse.json(
|
return jsonResponse(
|
||||||
{ error: lockStatus.reason },
|
{ error: lockStatus.reason },
|
||||||
{ status: 423 }
|
423
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,9 +100,9 @@ export async function POST(request: NextRequest) {
|
|||||||
false,
|
false,
|
||||||
"Invalid password"
|
"Invalid password"
|
||||||
)
|
)
|
||||||
return NextResponse.json(
|
return jsonResponse(
|
||||||
{ error: "Invalid email/username or password." },
|
{ error: "Invalid email/username or password." },
|
||||||
{ status: 401 }
|
401
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,17 +115,25 @@ export async function POST(request: NextRequest) {
|
|||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
|
||||||
await createSession(dbUser.id, dbUser.role_name)
|
const token = await createSession(dbUser.id, dbUser.role_name)
|
||||||
await setSessionContext(dbUser.id, ipAddress)
|
await setSessionContext(dbUser.id, ipAddress)
|
||||||
|
|
||||||
const user = mapDbUserToSessionUser(dbUser)
|
const user = mapDbUserToSessionUser(dbUser)
|
||||||
|
|
||||||
return NextResponse.json({ user }, { status: 200 })
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login error:", error)
|
console.error("Login error:", error)
|
||||||
return NextResponse.json(
|
return new Response(
|
||||||
{ error: "Authentication service unavailable." },
|
JSON.stringify({ error: "Authentication service unavailable." }),
|
||||||
{ status: 503 }
|
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { SESSION_COOKIE } from "@/lib/auth"
|
||||||
import { destroySession } from "@/lib/auth"
|
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
await destroySession()
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
return NextResponse.json({ success: true }, { status: 200 })
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0${process.env.NODE_ENV === "production" ? "; Secure" : ""}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Logout error:", error)
|
console.error("Logout error:", error)
|
||||||
return NextResponse.json(
|
return new Response(
|
||||||
{ error: "Logout failed." },
|
JSON.stringify({ error: "Logout failed." }),
|
||||||
{ status: 500 }
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { unlink } from "node:fs/promises"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { existsSync } from "node:fs"
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; messageId: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id: conversationId, messageId } = await params
|
||||||
|
|
||||||
|
// Verify user is a participant in this conversation
|
||||||
|
const partCheck = await query(
|
||||||
|
`SELECT 1 FROM conversation_participants WHERE conversation_id = $1 AND user_id = $2`,
|
||||||
|
[conversationId, user.id],
|
||||||
|
)
|
||||||
|
if (partCheck.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Not a participant" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the message to verify it belongs to the sender and check if it's a voice message
|
||||||
|
const msgResult = await query(
|
||||||
|
`SELECT id, sender_id, content FROM messages WHERE id = $1 AND conversation_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[messageId, conversationId],
|
||||||
|
)
|
||||||
|
if (msgResult.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Message not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = msgResult.rows[0]
|
||||||
|
|
||||||
|
// Only the sender can delete their own message
|
||||||
|
if (message.sender_id !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot delete another user's message" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the associated audio file if this is a voice message
|
||||||
|
let storageDeleted = false
|
||||||
|
try {
|
||||||
|
const content = message.content
|
||||||
|
if (content?.startsWith("{")) {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.v && parsed.u) {
|
||||||
|
const filename = parsed.u.replace(/^\/uploads\/voice\//, "")
|
||||||
|
const filePath = join(process.cwd(), "public", "uploads", "voice", filename)
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
await unlink(filePath)
|
||||||
|
storageDeleted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If file deletion fails, log but still proceed with DB deletion
|
||||||
|
console.warn(`[voice-delete] Failed to delete audio file for message ${messageId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft-delete the message in the database
|
||||||
|
const deleteResult = await query(
|
||||||
|
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND conversation_id = $2 RETURNING id`,
|
||||||
|
[messageId, conversationId],
|
||||||
|
)
|
||||||
|
|
||||||
|
const dbDeleted = deleteResult.rows.length > 0
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[voice-delete] id=${messageId} conv=${conversationId} user=${user.id} db=${dbDeleted} storage=${storageDeleted}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
messageId,
|
||||||
|
dbDeleted,
|
||||||
|
storageDeleted,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[voice-delete] Error:", error)
|
||||||
|
return NextResponse.json({ error: "Unable to delete voice note. Please try again." }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
import { hasBlockedCodeExtension } from "@/lib/blocked-extensions"
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
@@ -103,6 +104,18 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
return NextResponse.json({ error: "Message content is required" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server-side blocked code extension check
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(content)
|
||||||
|
if (parsed.fileAttachments && Array.isArray(parsed.fileAttachments)) {
|
||||||
|
for (const f of parsed.fileAttachments) {
|
||||||
|
if (f.name && hasBlockedCodeExtension(f.name)) {
|
||||||
|
return NextResponse.json({ error: `Blocked file type: ${f.name}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO messages (conversation_id, sender_id, content)
|
`INSERT INTO messages (conversation_id, sender_id, content)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -160,3 +173,62 @@ function formatTime(date: Date): string {
|
|||||||
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
return date.toLocaleDateString([], { month: "short", day: "numeric" }) + " " +
|
||||||
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await _request.json()
|
||||||
|
const messageId = body.messageId
|
||||||
|
if (!messageId) return NextResponse.json({ error: "messageId required" }, { status: 400 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE messages SET deleted_at = NOW() WHERE id = $1 AND sender_id = $2 AND conversation_id = $3 RETURNING id`,
|
||||||
|
[messageId, user.id, id],
|
||||||
|
)
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Delete message error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete message" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const body = await request.json()
|
||||||
|
const messageId = body.messageId
|
||||||
|
const newContent = body.content
|
||||||
|
if (!messageId || !newContent?.trim()) {
|
||||||
|
return NextResponse.json({ error: "messageId and content required" }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE messages SET content = $1, updated_at = NOW() WHERE id = $2 AND sender_id = $3 AND conversation_id = $4 AND deleted_at IS NULL RETURNING id`,
|
||||||
|
[newContent.trim(), messageId, user.id, id],
|
||||||
|
)
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Message not found or not yours" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Edit message error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to edit message" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { getSessionUser } from "@/lib/auth"
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query, transaction } from "@/lib/db"
|
||||||
import { avatarSvgUrl } from "@/lib/avatar"
|
import { avatarSvgUrl } from "@/lib/avatar"
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -18,13 +18,18 @@ export async function GET() {
|
|||||||
u.email AS other_user_email,
|
u.email AS other_user_email,
|
||||||
u.phone AS other_user_phone,
|
u.phone AS other_user_phone,
|
||||||
u.avatar_url AS other_user_avatar_url,
|
u.avatar_url AS other_user_avatar_url,
|
||||||
(SELECT content FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message,
|
lm.last_message_data->>'content' AS last_message,
|
||||||
(SELECT created_at FROM messages WHERE conversation_id = c.id ORDER BY created_at DESC LIMIT 1) AS last_message_time,
|
(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
|
(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
|
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_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 conversation_participants cp ON cp.conversation_id = c.id
|
||||||
JOIN users u ON u.id = cp.user_id AND u.id != $1
|
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 (
|
WHERE c.id IN (
|
||||||
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
SELECT conversation_id FROM conversation_participants WHERE user_id = $1
|
||||||
)
|
)
|
||||||
@@ -40,7 +45,6 @@ export async function GET() {
|
|||||||
id: row.other_user_id,
|
id: row.other_user_id,
|
||||||
name: row.other_user_name,
|
name: row.other_user_name,
|
||||||
email: row.other_user_email,
|
email: row.other_user_email,
|
||||||
phone: row.other_user_phone || "",
|
|
||||||
avatar: avatarSvgUrl(row.other_user_name),
|
avatar: avatarSvgUrl(row.other_user_name),
|
||||||
},
|
},
|
||||||
lastMessage: row.last_message || "",
|
lastMessage: row.last_message || "",
|
||||||
@@ -81,23 +85,27 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ conversationId: existing.rows[0].id })
|
return NextResponse.json({ conversationId: existing.rows[0].id })
|
||||||
}
|
}
|
||||||
|
|
||||||
const convResult = await query(
|
const result = await transaction(async (client) => {
|
||||||
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
const convResult = await client.query(
|
||||||
)
|
`INSERT INTO conversations DEFAULT VALUES RETURNING id`,
|
||||||
const conversationId = convResult.rows[0].id
|
)
|
||||||
|
const conversationId = convResult.rows[0].id
|
||||||
|
|
||||||
await query(
|
await client.query(
|
||||||
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
`INSERT INTO conversation_participants (conversation_id, user_id, last_read_at) VALUES ($1, $2, NOW()), ($1, $3, NOW())`,
|
||||||
[conversationId, user.id, userId],
|
[conversationId, user.id, userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const otherUser = await query(
|
const otherResult = await client.query(
|
||||||
`SELECT id, first_name || ' ' || last_name AS name, email, phone, avatar_url
|
`SELECT id, first_name || ' ' || last_name AS name, email, avatar_url
|
||||||
FROM users WHERE id = $1`,
|
FROM users WHERE id = $1`,
|
||||||
[userId],
|
[userId],
|
||||||
)
|
)
|
||||||
|
|
||||||
const other = otherUser.rows[0]
|
return { conversationId, other: otherResult.rows[0] }
|
||||||
|
})
|
||||||
|
|
||||||
|
const { conversationId, other } = result
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
conversation: {
|
conversation: {
|
||||||
@@ -107,7 +115,6 @@ export async function POST(request: NextRequest) {
|
|||||||
id: other.id,
|
id: other.id,
|
||||||
name: other.name,
|
name: other.name,
|
||||||
email: other.email,
|
email: other.email,
|
||||||
phone: other.phone || "",
|
|
||||||
avatar: avatarSvgUrl(other.name),
|
avatar: avatarSvgUrl(other.name),
|
||||||
},
|
},
|
||||||
lastMessage: "",
|
lastMessage: "",
|
||||||
|
|||||||
+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 } {
|
function computeTrend(current: number, previous: number): { pct: number; up: boolean } {
|
||||||
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
if (previous === 0) return { pct: current > 0 ? 100 : 0, up: current > 0 }
|
||||||
const pct = Math.round(((current - previous) / previous) * 100)
|
const pct = Math.round(((current - previous) / previous) * 100)
|
||||||
return { pct: Math.abs(pct), up: pct >= 0 }
|
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) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const user = await getSessionUser()
|
const user = await getSessionUser()
|
||||||
@@ -138,19 +86,107 @@ export async function GET(request: NextRequest) {
|
|||||||
prevRange = getPreviousPeriodRange(period, start)
|
prevRange = getPreviousPeriodRange(period, start)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [currentLeads, prevLeads] = await Promise.all([
|
const ownerFilter = isAdmin ? "" : "AND l.assigned_to = $3"
|
||||||
fetchLeadsInRange(start, end, user.id, isAdmin),
|
|
||||||
fetchLeadsInRange(prevRange.start, prevRange.end, user.id, isAdmin),
|
|
||||||
])
|
|
||||||
|
|
||||||
const currentCounts = countStatuses(currentLeads)
|
// Status counts for current period (SQL aggregation)
|
||||||
const prevCounts = countStatuses(prevLeads)
|
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 closedLeads = currentCounts.closed
|
||||||
const conversionRate = totalLeads > 0 ? Math.round((closedLeads / totalLeads) * 100) : 0
|
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,
|
id: r.id,
|
||||||
companyName: r.company_name || "",
|
companyName: r.company_name || "",
|
||||||
contactName: r.contact_name,
|
contactName: r.contact_name,
|
||||||
@@ -158,7 +194,7 @@ export async function GET(request: NextRequest) {
|
|||||||
phone: r.phone || "",
|
phone: r.phone || "",
|
||||||
source: "",
|
source: "",
|
||||||
description: r.notes || "",
|
description: r.notes || "",
|
||||||
status: r.status,
|
status: stageToStatus(r.stage_name),
|
||||||
assignedUserId: r.assigned_to,
|
assignedUserId: r.assigned_to,
|
||||||
assignedUser: r.assigned_to ? {
|
assignedUser: r.assigned_to ? {
|
||||||
id: r.user_id,
|
id: r.user_id,
|
||||||
@@ -170,17 +206,14 @@ export async function GET(request: NextRequest) {
|
|||||||
updatedAt: r.updated_at,
|
updatedAt: r.updated_at,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const monthlyBreakdown = buildMonthlyBreakdown(currentLeads, period)
|
|
||||||
|
|
||||||
const trends = {
|
const trends = {
|
||||||
totalLeads: computeTrend(currentCounts.open + currentCounts.contacted + currentCounts.pending + currentCounts.closed + currentCounts.ignored,
|
totalLeads: computeTrend(totalLeads, prevTotal),
|
||||||
prevCounts.open + prevCounts.contacted + prevCounts.pending + prevCounts.closed + prevCounts.ignored),
|
|
||||||
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
openLeads: computeTrend(currentCounts.open, prevCounts.open),
|
||||||
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
contactedLeads: computeTrend(currentCounts.contacted, prevCounts.contacted),
|
||||||
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
pendingLeads: computeTrend(currentCounts.pending, prevCounts.pending),
|
||||||
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
closedLeads: computeTrend(currentCounts.closed, prevCounts.closed),
|
||||||
conversionRate: computeTrend(conversionRate,
|
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 = {
|
const stats = {
|
||||||
@@ -192,9 +225,9 @@ export async function GET(request: NextRequest) {
|
|||||||
ignoredLeads: currentCounts.ignored,
|
ignoredLeads: currentCounts.ignored,
|
||||||
conversionRate,
|
conversionRate,
|
||||||
monthlyBreakdown,
|
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,
|
trends,
|
||||||
recentLeads: mappedLeads.slice(0, 10),
|
recentLeads,
|
||||||
statusDistribution: [
|
statusDistribution: [
|
||||||
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
|
||||||
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
||||||
|
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT id, recipient, subject, body_text, created_at FROM sent_emails ORDER BY created_at DESC LIMIT 50`,
|
||||||
|
)
|
||||||
|
|
||||||
|
return NextResponse.json({ emails: result.rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
recipient: r.recipient,
|
||||||
|
subject: r.subject,
|
||||||
|
bodyText: r.body_text,
|
||||||
|
createdAt: r.created_at,
|
||||||
|
})) })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Emails GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load sent emails" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT 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],
|
||||||
|
)
|
||||||
|
|
||||||
|
const users = result.rows.map((r: any) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: `${r.first_name} ${r.last_name}`,
|
||||||
|
email: r.email,
|
||||||
|
role: r.role_display || r.role_name || "",
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ users })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Event users error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load users" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { buildIcs } from "@/lib/ics"
|
||||||
|
|
||||||
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`SELECT e.id, e.user_id, e.participant_id, e.title, e.description, e.event_type,
|
||||||
|
e.start_time, e.end_time, e.duration_minutes, e.status,
|
||||||
|
u.email AS user_email, u.first_name AS user_first, u.last_name AS user_last,
|
||||||
|
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
|
||||||
|
FROM scheduled_events e
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
LEFT JOIN users p ON p.id = e.participant_id
|
||||||
|
WHERE e.id = $1 AND e.user_id = $2`,
|
||||||
|
[id, user.id],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = result.rows[0]
|
||||||
|
|
||||||
|
const icsContent = buildIcs({
|
||||||
|
uid: r.id,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description || undefined,
|
||||||
|
startTime: new Date(r.start_time),
|
||||||
|
endTime: r.end_time ? new Date(r.end_time) : undefined,
|
||||||
|
durationMinutes: r.duration_minutes || undefined,
|
||||||
|
organizerName: `${r.user_first} ${r.user_last}`,
|
||||||
|
organizerEmail: r.user_email,
|
||||||
|
attendeeName: r.p_first ? `${r.p_first} ${r.p_last}` : undefined,
|
||||||
|
attendeeEmail: r.p_email || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
return new NextResponse(icsContent, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/calendar; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="event-${r.id}.ics"`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ICS GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to generate calendar file" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query } from "@/lib/db"
|
||||||
|
import { sendEventRescheduled } from "@/lib/email"
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
const { 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.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
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
LEFT JOIN users p ON p.id = e.participant_id
|
||||||
|
WHERE e.id = $1`,
|
||||||
|
[id],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
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 && !isDeveloper) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCreator && (
|
||||||
|
(title !== undefined && title !== old.title) ||
|
||||||
|
(eventType !== undefined && eventType !== old.event_type) ||
|
||||||
|
(startTime !== undefined && startTime !== old.start_time) ||
|
||||||
|
(endTime !== undefined && endTime !== old.end_time) ||
|
||||||
|
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
|
||||||
|
(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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await query(
|
||||||
|
`UPDATE scheduled_events SET
|
||||||
|
title = COALESCE($1, title),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
participant_notes = COALESCE($3, participant_notes),
|
||||||
|
event_type = COALESCE($4, event_type),
|
||||||
|
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 = $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,
|
||||||
|
participantNotes ?? null,
|
||||||
|
eventType || null,
|
||||||
|
startTime || null,
|
||||||
|
endTime ?? null,
|
||||||
|
durationMinutes ?? null,
|
||||||
|
status || null,
|
||||||
|
participantId ?? null,
|
||||||
|
developerId ?? null,
|
||||||
|
clientName ?? null,
|
||||||
|
clientEmail ?? null,
|
||||||
|
clientPhone ?? null,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (isChanged && r.participant_id && r.participant_id !== user.id) {
|
||||||
|
sendEventRescheduled({
|
||||||
|
creatorName: `${user.firstName} ${user.lastName}`,
|
||||||
|
creatorEmail: user.email,
|
||||||
|
participantName: old.p_first ? `${old.p_first} ${old.p_last}` : null,
|
||||||
|
participantEmail: old.p_email || null,
|
||||||
|
title: r.title,
|
||||||
|
description: r.description || undefined,
|
||||||
|
eventType: r.event_type,
|
||||||
|
startTime: r.start_time,
|
||||||
|
endTime: r.end_time || undefined,
|
||||||
|
durationMinutes: r.duration_minutes || undefined,
|
||||||
|
}).catch((err) => console.error("Reschedule email error:", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
const creatorResult = await query(
|
||||||
|
`SELECT u.id, u.first_name, u.last_name, u.email, u.avatar_url,
|
||||||
|
ur.role_id, r.name AS role_name, r.display_name AS role_display
|
||||||
|
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.id = $1`,
|
||||||
|
[old.user_id],
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events PATCH error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to update event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { id } = await params
|
||||||
|
|
||||||
|
const existing = await query(
|
||||||
|
`SELECT user_id FROM scheduled_events WHERE id = $1`,
|
||||||
|
[id],
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existing.rows.length === 0) {
|
||||||
|
return NextResponse.json({ error: "Event not found" }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.rows[0].user_id !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(`DELETE FROM scheduled_events WHERE id = $1`, [id], user.id)
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events DELETE error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to delete event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { query, transaction } from "@/lib/db"
|
||||||
|
import { sendEventConfirmation } from "@/lib/email"
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const start = searchParams.get("start")
|
||||||
|
const end = searchParams.get("end")
|
||||||
|
const status = searchParams.get("status")
|
||||||
|
|
||||||
|
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 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} OR e.developer_id = $${idx}`
|
||||||
|
params.push(user.id)
|
||||||
|
idx++
|
||||||
|
} else {
|
||||||
|
sql += ` WHERE 1=1`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (start) {
|
||||||
|
sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
|
||||||
|
params.push(start)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end) {
|
||||||
|
sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
|
||||||
|
params.push(end)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
sql += ` AND e.status = $${idx}`
|
||||||
|
params.push(status)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += " ORDER BY e.start_time ASC"
|
||||||
|
|
||||||
|
const result = await query(sql, params, user.id)
|
||||||
|
|
||||||
|
const events = result.rows.map((r: any) => ({
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({ events })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events GET error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to load events" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const {
|
||||||
|
participantId, developerId, leadId, conversationId,
|
||||||
|
title, description, eventType,
|
||||||
|
startTime, endTime, durationMinutes,
|
||||||
|
clientName, clientEmail, clientPhone,
|
||||||
|
} = await request.json()
|
||||||
|
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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`,
|
||||||
|
[
|
||||||
|
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]
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
participantName: participant ? `${participant.first_name} ${participant.last_name}` : null,
|
||||||
|
participantEmail: participant?.email || null,
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
eventType: eventType || "meeting",
|
||||||
|
startTime,
|
||||||
|
endTime: endTime || undefined,
|
||||||
|
durationMinutes: durationMinutes || undefined,
|
||||||
|
}).catch((err) => console.error("Email error:", err))
|
||||||
|
|
||||||
|
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, 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: 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,
|
||||||
|
clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
|
||||||
|
},
|
||||||
|
}, { status: 201 })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Events POST error:", error)
|
||||||
|
return NextResponse.json({ error: "Failed to create event" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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 })
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const q = searchParams.get("q") || ""
|
||||||
|
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
|
||||||
|
const pos = parseInt(searchParams.get("pos") || "0", 10) || 0
|
||||||
|
|
||||||
|
if (!GIPHY_API_KEY) {
|
||||||
|
console.error("Missing GIPHY_API_KEY environment variable")
|
||||||
|
return NextResponse.json({ results: [], noKey: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any
|
||||||
|
|
||||||
|
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) })
|
||||||
|
|
||||||
|
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 results = (data.data || []).map((item: any) => {
|
||||||
|
const dims = item.images?.original
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
title: item.title || "",
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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 { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser, setSessionContext } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
import crypto from "crypto"
|
import crypto from "crypto"
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone number required" }, { status: 400 })
|
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(
|
await query(
|
||||||
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
`INSERT INTO invites (token, phone) VALUES ($1, $2)`,
|
||||||
[token, phone],
|
[token, phone],
|
||||||
@@ -19,7 +42,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const inviteUrl = `${origin}/join/${token}`
|
const inviteUrl = `${origin}/join/${token}`
|
||||||
|
|
||||||
return NextResponse.json({ token, inviteUrl })
|
return NextResponse.json({ token, inviteUrl })
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
console.error("Invite generation error:", error)
|
||||||
return NextResponse.json({ error: "Failed to generate invite" }, { status: 500 })
|
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 offset = parseInt(searchParams.get("offset") || "0", 10)
|
||||||
const isAdmin = user.role === "admin" || user.role === "super_admin"
|
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[] = []
|
const params: any[] = []
|
||||||
let paramIdx = 1
|
let paramIdx = 1
|
||||||
|
|
||||||
|
const conditions: string[] = ["l.deleted_at IS NULL"]
|
||||||
|
|
||||||
if (period !== "all") {
|
if (period !== "all") {
|
||||||
const range = getPeriodDateRange(period)
|
const range = getPeriodDateRange(period)
|
||||||
if (range) {
|
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())
|
params.push(range.start.toISOString(), range.end.toISOString())
|
||||||
paramIdx += 2
|
paramIdx += 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
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}%`)
|
params.push(`%${search}%`)
|
||||||
paramIdx++
|
paramIdx++
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
sql += ` AND l.assigned_to = $${paramIdx}`
|
conditions.push(`l.assigned_to = $${paramIdx}`)
|
||||||
params.push(user.id)
|
params.push(user.id)
|
||||||
paramIdx++
|
paramIdx++
|
||||||
}
|
}
|
||||||
|
|
||||||
sql += ` ORDER BY l.created_at DESC`
|
// Push status filter into SQL (avoid client-side filtering after pagination)
|
||||||
sql += ` LIMIT $${paramIdx} OFFSET $${paramIdx + 1}`
|
const statusStageMap: Record<string, string[]> = {
|
||||||
params.push(limit, offset)
|
open: ["New"],
|
||||||
paramIdx += 2
|
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)
|
const s = stageToStatus(r.stage_name)
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -106,11 +133,7 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (status !== "all") {
|
return NextResponse.json({ leads, total })
|
||||||
leads = leads.filter((l: any) => l.status === status)
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json(leads)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Leads API error:", error)
|
console.error("Leads API error:", error)
|
||||||
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to load leads" }, { status: 500 })
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ export async function PATCH(request: NextRequest) {
|
|||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
|
|
||||||
await query(
|
const result = await query(
|
||||||
`UPDATE company_settings SET
|
`UPDATE company_settings SET
|
||||||
company_name = $1, company_email = $2, company_phone = $3,
|
company_name = $1, company_email = $2, company_phone = $3,
|
||||||
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()`,
|
company_website = $4, company_address = $5, updated_by = $6, updated_at = NOW()
|
||||||
|
WHERE id = (SELECT id FROM company_settings ORDER BY updated_at DESC LIMIT 1)`,
|
||||||
[
|
[
|
||||||
body.companyName || "",
|
body.companyName || "",
|
||||||
body.companyEmail || "",
|
body.companyEmail || "",
|
||||||
@@ -60,6 +61,21 @@ export async function PATCH(request: NextRequest) {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO company_settings (company_name, company_email, company_phone, company_website, company_address, updated_by)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||||
|
[
|
||||||
|
body.companyName || "",
|
||||||
|
body.companyEmail || "",
|
||||||
|
body.companyPhone || "",
|
||||||
|
body.companyWebsite || "",
|
||||||
|
body.companyAddress || "",
|
||||||
|
user.id,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ success: true })
|
return NextResponse.json({ success: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Company settings PATCH error:", error)
|
console.error("Company settings PATCH error:", error)
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ export async function GET() {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`SELECT 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],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
const websiteTheme = result.rows[0]?.website_theme || "default"
|
||||||
return NextResponse.json({ websiteTheme })
|
const colorTheme = result.rows[0]?.color_theme || null
|
||||||
|
return NextResponse.json({ websiteTheme, colorTheme })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme GET error:", error)
|
console.error("Website theme GET error:", error)
|
||||||
return NextResponse.json({ error: "Failed to load website theme" }, { status: 500 })
|
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 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
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(
|
if (Object.keys(update).length > 0) {
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
await query(
|
||||||
[user.id, JSON.stringify({ website_theme: theme })],
|
`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) {
|
} catch (error) {
|
||||||
console.error("Website theme PUT error:", error)
|
console.error("Website theme PUT error:", error)
|
||||||
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
return NextResponse.json({ error: "Failed to save website theme" }, { status: 500 })
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ let prevTime = Date.now()
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const sessionUser = await getSessionUser()
|
const sessionUser = await getSessionUser()
|
||||||
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!sessionUser) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
if (sessionUser.role !== "admin" && sessionUser.role !== "super_admin") {
|
|
||||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const elapsed = now - prevTime
|
const elapsed = now - prevTime
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
|
import { writeFile } from "node:fs/promises"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getSessionUser()
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
|
const formData = await request.formData()
|
||||||
|
const file = formData.get("audio") as File | null
|
||||||
|
if (!file) return NextResponse.json({ error: "No audio file" }, { status: 400 })
|
||||||
|
|
||||||
|
const duration = parseFloat(formData.get("duration")?.toString() || "0")
|
||||||
|
|
||||||
|
const ext = file.name.endsWith(".webm") ? "webm" : "webm"
|
||||||
|
const filename = `${crypto.randomUUID()}.${ext}`
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer())
|
||||||
|
const savePath = join(process.cwd(), "public", "uploads", "voice", filename)
|
||||||
|
await writeFile(savePath, buffer)
|
||||||
|
|
||||||
|
return NextResponse.json({ url: `/uploads/voice/${filename}`, duration })
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Voice upload error:", error)
|
||||||
|
return NextResponse.json({ error: "Upload failed" }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,17 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { getSessionUser } from "@/lib/auth"
|
||||||
import { query } from "@/lib/db"
|
import { query } from "@/lib/db"
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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")
|
const phone = request.nextUrl.searchParams.get("phone")
|
||||||
if (!phone) {
|
if (!phone) {
|
||||||
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
return NextResponse.json({ error: "Phone parameter required" }, { status: 400 })
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
return NextResponse.json({ error: "Invalid role" }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
await setSessionContext(sessionUser.id)
|
|
||||||
|
|
||||||
const nameParts = name.trim().split(/\s+/)
|
const nameParts = name.trim().split(/\s+/)
|
||||||
const firstName = nameParts[0]
|
const firstName = nameParts[0]
|
||||||
const lastName = nameParts.slice(1).join(" ") || firstName
|
const lastName = nameParts.slice(1).join(" ") || firstName
|
||||||
@@ -72,6 +70,8 @@ export async function POST(request: NextRequest) {
|
|||||||
const passwordHash = await hashPassword(password)
|
const passwordHash = await hashPassword(password)
|
||||||
const passwordEncrypted = await encryptPassword(password)
|
const passwordEncrypted = await encryptPassword(password)
|
||||||
|
|
||||||
|
await setSessionContext(sessionUser.id)
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
`INSERT INTO users (username, email, password_hash, password_encrypted, first_name, last_name, is_active, created_by)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
|||||||
@@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
if (!joined) {
|
if (!joined) {
|
||||||
return (
|
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="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-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<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 ? (
|
{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 ? (
|
) : !isReady ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col items-center gap-4 py-6">
|
<div className="flex flex-col items-center gap-4 py-6">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
|
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
|
||||||
<p className="text-[#888888] text-sm">Connecting to call...</p>
|
<p className="text-[#8A9078] text-sm">Connecting to call...</p>
|
||||||
</div>
|
</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 && (
|
{micError && (
|
||||||
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
|
<p className="text-[#C84B4B] text-xs mb-4">{micError}</p>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -78,12 +78,12 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
onChange={(e) => setDisplayName(e.target.value)}
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
|
||||||
placeholder="Enter your name"
|
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
|
<button
|
||||||
onClick={handleJoin}
|
onClick={handleJoin}
|
||||||
disabled={!displayName.trim()}
|
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" />
|
<Phone className="h-4 w-4" />
|
||||||
Join Call
|
Join Call
|
||||||
@@ -96,14 +96,14 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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="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-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
|
<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" && (
|
{callState === "calling" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Connecting to call room...</p>
|
||||||
<div className="flex justify-center mt-6">
|
<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" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "waiting" && (
|
{callState === "waiting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] 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>
|
<p className="text-[#8A9078] text-sm animate-pulse">Share the call link to invite someone...</p>
|
||||||
{participants.length > 0 && (
|
{participants.length > 0 && (
|
||||||
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mt-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{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>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-center mt-6">
|
<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" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "participant_joined" && (
|
{callState === "participant_joined" && (
|
||||||
<div>
|
<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 && (
|
{participants.length > 0 && (
|
||||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{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>
|
||||||
)}
|
)}
|
||||||
<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">
|
<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" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "connecting" && (
|
{callState === "connecting" && (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
|
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
|
||||||
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
|
<p className="text-[#8A9078] text-sm animate-pulse">Establishing secure connection...</p>
|
||||||
<div className="flex justify-center mt-6">
|
<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" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{callState === "connected" && (
|
{callState === "connected" && (
|
||||||
<div>
|
<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 && (
|
{participants.length > 0 && (
|
||||||
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
|
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
|
||||||
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
|
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
|
||||||
<Users className="h-3 w-3" />
|
<Users className="h-3 w-3" />
|
||||||
Participants ({participants.length})
|
Participants ({participants.length})
|
||||||
</div>
|
</div>
|
||||||
{participants.map((p) => (
|
{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>
|
||||||
)}
|
)}
|
||||||
<div className="text-[#CC0000] font-mono text-lg mb-6">
|
<div className="text-[#C84B4B] font-mono text-lg mb-6">
|
||||||
{formatDuration(callDuration)}
|
{formatDuration(callDuration)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-center gap-4">
|
<div className="flex justify-center gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={toggleSpeaker}
|
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" />}
|
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
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" />}
|
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleEnd}
|
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" />
|
<PhoneOff className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
|
|||||||
|
|
||||||
{(callState === "ended" || callState === "idle") && (
|
{(callState === "ended" || callState === "idle") && (
|
||||||
<div>
|
<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 && (
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
|
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user