Compare commits

...

61 Commits

Author SHA1 Message Date
Hannah_Bagga c9c855579b Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Waiting to run
2026-07-10 14:44:01 +02:00
Hannah_Bagga 3a348e3616 Dark green hover fixed 2026-07-10 14:43:49 +02:00
Ace dba4c84cd5 Added finishing touch on other languages
Build & Auto-Repair / build (push) Has been cancelled
2026-07-08 14:24:03 +02:00
Ace d77ff2b965 Added logic to the 4 languages so it can search each language at a time
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:08:21 +02:00
Ace 0bc3ca58ed Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-07 10:03:38 +02:00
Ace d793604e92 Added 4 Languages English, Afrikaans, Xhosa, Zulu, tested but brought leads down to 3 leads only will see how to make it more without losing effiency of the model. 2026-07-07 10:03:35 +02:00
TroodonEnjoyer bc83af8e00 Started PostgreSQL — data directory was uninitialized, ran initdb, set password, switched to md5 auth, all 24 migrations applied.
Build & Auto-Repair / build (push) Has been cancelled
Hardened db.ts — added statement_timeout: 30000, idle_in_transaction_session_timeout: 10000, created transaction() helper (BEGIN/COMMIT/ROLLBACK).
Multi-step API routes wrapped in transactions — Events POST, Conversations POST use atomic blocks.
Fixed leads pagination — status filter pushed into SQL WHERE (no client-side filtering after LIMIT/OFFSET), added SELECT COUNT(*) for total.
Rewrote dashboard — SQL aggregations (COUNT + GROUP BY + date_trunc) replace loading all leads into memory.
Optimized conversations GET — merged duplicate correlated subqueries into single LEFT JOIN LATERAL.
Created migration 021_performance_indexes.sql — 8 missing indexes + set_session_user_context() function caching current_user_hierarchy_level() as session variable (avoids per-query RLS join).
Fixed build error — duplicate const other in conversations route. Also fixed a TypeScript never error in dashboard breakdown map.
Verified — npx tsc --noEmit clean, npm test (13 pass, 7 integration skip gracefully), npx next build succeeds.
2026-07-06 13:36:48 +02:00
Hannah_Bagga 80dee367e8 fixed theme setting
Build & Auto-Repair / build (push) Has been cancelled
2026-07-05 21:54:39 +02:00
Hannah_Bagga da99b695be just some things i needed to get back into the CRM
Build & Auto-Repair / build (push) Has been cancelled
2026-07-03 16:05:03 +02:00
caitlin dd6767980a search button theme fix in ai assistant
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 16:04:53 +02:00
Ace c1c4afadbb Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-02 13:26:58 +02:00
Ace 37679a7a60 Added Ai load fix 2026-07-02 13:26:56 +02:00
Hannah_Bagga c0cb715ced ai assistant page fixed
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 21:07:42 +02:00
Hannah_Bagga 1269f6cce8 Revert "Removed quick tips, recent prompts Messages today, Tokens used"
Build & Auto-Repair / build (push) Has been cancelled
This reverts commit 370f935cbb.
2026-07-01 14:36:47 +02:00
Hannah_Bagga 370f935cbb Removed quick tips, recent prompts Messages today, Tokens used
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 14:24:32 +02:00
Ace faf9dd551a Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:16:59 +02:00
Ace f67d9377a0 trying to fix AI chat 2026-07-01 13:16:57 +02:00
Hannah_Bagga 38fb3a47a8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:14:35 +02:00
Hannah_Bagga bc422edcf7 colour scheme problem fixed 2026-07-01 13:13:31 +02:00
TroodonEnjoyer da5f8360bc Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 13:07:32 +02:00
TroodonEnjoyer 37af1febcc Automate DB migrations + fix audit trigger + drop forced password change
- scripts/run-migrations.mjs: auto-applies unapplied .sql files on
  npm run dev/setup via psql, tracks state in _migrations table
- 020_fixes.sql: sets password_change_required=FALSE for all users,
  fixes audit_password_change UUID cast, re-enables trigger
- package.json: hook migrations into dev:precheck
- setup.mjs: hook into npm run setup
- run_all.sql: add 020_fixes.sql to migration order
2026-07-01 13:07:15 +02:00
caitlin 84632e043f Merge origin/main into main
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:40:53 +02:00
Ace b2bb6d94f5 added the search button for the AI
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:29:06 +02:00
caitlin 566fa3df88 Fixed select a job category for lightmode 2026-07-01 12:21:16 +02:00
Ace 16710e3019 Getting the 10 syncs and adding my AI that works but has issues
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:16:22 +02:00
Ace e920d4925a Updated the to use targeted jobs but its weird 2026-07-01 12:15:37 +02:00
caitlin e8d80c3a16 login logo fix that i broke
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 12:04:05 +02:00
caitlin 77cccba8a1 Fixed Dark Mode
Build & Auto-Repair / build (push) Has been cancelled
2026-07-01 11:43:21 +02:00
Hannah_Bagga 7a5e833722 GIFs work now
Build & Auto-Repair / build (push) Has been cancelled
2026-06-30 15:11:15 +02:00
Hannah_Bagga 1e8f979cf9 AI Assistant page looks better.
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 22:00:05 +02:00
Hannah_Bagga 98145f0264 light and dark mode fixed and default mode is the default unless you pick Spidey mode
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 19:28:35 +02:00
caitlin 7a722a7d7e Merge remote-tracking branch 'origin/main' into master
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:38:18 +02:00
caitlin 61e9fac352 Fixed Light Mode 2026-06-29 15:26:44 +02:00
Ace 878627a6ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 15:08:09 +02:00
Ace 2c3a8e5333 Added qwen to serve as an repair agent. 2026-06-29 15:06:03 +02:00
Hannah_Bagga d870fb3ac7 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 14:52:13 +02:00
Hannah_Bagga f6d1d91fc6 AI Chat page looks better. Caitlin is my witness 2026-06-29 14:51:59 +02:00
TroodonEnjoyer bc3e345a27 Fix login session persistence + add admin accounts
- Fix JWT secret regenerating on every HMR cycle (globalThis cache)
- Replace unreliable response.cookies.set() with manual Set-Cookie header
- Remove jwt-secret.ts abstraction — JWT_SECRET back inline in auth.ts
- Sidebar logo links directly to /dashboard instead of server-redirect /
- Add 3 admin users to seed: ewan/caitlin/dillen (ADMIN role)
- Fix bcrypt hash corruption from PowerShell $ interpolation
- Remove plaintext passwords from codebase
- Fix audit_password_change trigger UUID type error
2026-06-29 14:42:16 +02:00
caitlin 3c0e7d76ca Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 10:42:58 +02:00
TroodonEnjoyer 39fb39db12 Fixed the login again? 2026-06-26 22:30:54 +02:00
Ace cd37d5a987 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 21:36:17 +02:00
Ace d138c60203 Fixing calander 2026-06-26 21:36:09 +02:00
caitlin 4d7a59c278 Changes in lightmode and dark mode header 2026-06-26 21:31:20 +02:00
TroodonEnjoyer aac9817ee7 Fix double login: set cookie on response object, invalidate sessions on dev restart
- Move cookie setting from cookies().set() to response.cookies.set() in
  login/logout routes (Next.js 15 ambient cookie merge is unreliable)
- Generate random JWT_SECRET at module scope in auth.ts for dev mode so
  every server start invalidates all prior sessions
- Replace blank page on auth failure with redirect to /login in dashboard layout
- Preserve ?redirect= param from middleware in login form
- Wrap login page in Suspense boundary for useSearchParams()

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
caitlin 886a7efe91 Ficed the leads per month in dev account 2026-06-26 19:52:02 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
Hannah_Bagga 46a3216386 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:37 +02:00
Ace 8aba5c9ef4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:34:05 +02:00
Ace 06181625c3 AI back please test out and tell me added, filter words for AI! 2026-06-26 19:31:07 +02:00
caitlin 08eba574f8 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:27:22 +02:00
Hannah_Bagga a876f2d769 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 19:23:02 +02:00
caitlin fbb37d6777 Fixed sidebar colur and Cpu and Ram 2026-06-26 19:22:57 +02:00
Hannah_Bagga 214c4a1852 Spiderman Theme Fixed 2026-06-26 19:22:54 +02:00
Ace c669ec0d04 added browser use for AI 2026-06-26 19:18:01 +02:00
Ace bf6fbe3ee4 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 18:47:54 +02:00
Ace d6534eb338 Commit Test 2026-06-26 18:47:39 +02:00
Ace a08f23ab0f Fixed the messages, chats, voice note, voice call, stickers, gifs, emoji's , file attachments 2026-06-26 18:47:35 +02:00
115 changed files with 7313 additions and 1572 deletions
+58
View File
@@ -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
+42
View File
@@ -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 />;
}
+54
View File
@@ -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
}
+95
View File
@@ -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;
}
+141
View File
@@ -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
}
+76
View File
@@ -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)
);
}
+35
View File
@@ -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%;
}
+25 -6
View File
@@ -4,14 +4,37 @@
============================================================================ */ ============================================================================ */
.theme-spidey { .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%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%; --popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%; --muted: 217.2 32.6% 17.5%;
@@ -22,16 +45,12 @@
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar: 222.2 84% 4.9%; --sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%; --sidebar-foreground: 210 40% 98%;
--sidebar-primary: 0 100% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%; --sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%; --sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
} }
.theme-spidey body::before { .theme-spidey body::before {
-64
View File
@@ -1,64 +0,0 @@
/* ============================================================================
Starforce Theme — Dark cosmic with crimson accents and neon green glow
Captures the current starfield + drift + glow effects used live.
============================================================================ */
.theme-starforce {
--background: 0 0% 10%;
--foreground: 210 40% 98%;
--card: 0 0% 9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 11%;
--popover-foreground: 210 40% 98%;
--primary: 0 100% 53%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 0 100% 53%;
--radius: 0.5rem;
--sidebar: 0 0% 11%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 145 65% 50%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217.2 32.6% 17.5%;
--sidebar-ring: 0 100% 53%;
}
.theme-starforce body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
-59
View File
@@ -1,59 +0,0 @@
{
"theme": {
"id": "starforce",
"name": "Starforce",
"description": "Dark cosmic theme with crimson accents, starfield background, and neon green glow effects",
"type": "dark",
"default": true
},
"colors": {
"background": "hsl(0, 0%, 10%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(0, 0%, 9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 11%)",
"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(0, 0%, 11%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(145, 65%, 50%)",
"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": "#1a1a1a",
"card": "#171717",
"sidebar": "#1c1c1c",
"border": "#2a2a35",
"text": "#e8e8ef",
"mutedText": "#888888",
"glow": "#39ff14"
},
"effects": {
"starfield": true,
"starTwinkle": true,
"starDrift": true,
"textGlow": "#39ff14"
}
}
+225 -74
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") 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 [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .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]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true) setFormSaving(true)
try { try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +291,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
> >
<Icon className="h-2 w-2 shrink-0 opacity-50" /> <Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span> <span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div> </div>
</button> </button>
) )
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})} })}
</div> </div>
</div> </div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div> </div>
</div> </div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status} {event.status}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium"> {event.startTime ? (
<Clock className="h-3 w-3" /> <div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
{formatDate(event.startTime)} <Clock className="h-3 w-3" />
<span className="text-muted-foreground/20">·</span> {formatDate(event.startTime)}
{formatTime(event.startTime)} <span className="text-muted-foreground/20">·</span>
</div> {formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && ( {event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium"> <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" /> <Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div> </div>
)} )}
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<span className={cn( {event.developerId === user?.id ? (
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1", <>
event.userId === user?.id <span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
? "bg-primary/10 text-primary ring-primary/20" {event.creator?.name.charAt(0) || "?"}
: "bg-muted text-muted-foreground/60 ring-border", </span>
)}> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id Assigned by {event.creator?.name || "Unknown"}
? (event.participant?.name.charAt(0) || "?") </span>
: (event.creator?.name.charAt(0) || "?")} </>
</span> ) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium"> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id {event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
? (event.participant?.name || "No participant") </span>
: (event.creator?.name || "Unknown")} ) : null}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <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" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <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" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" /> <Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div> </div>
</div> </div>
<DialogFooter className="flex items-center justify-between border-t pt-4"> <DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +808,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </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 */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p> <p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </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 */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div> </div>
</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) */} {/* 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="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"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <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")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+6 -1
View File
@@ -1,10 +1,11 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other" export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled" export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent { export interface CalendarEvent {
id: string id: string
userId: string userId: string
participantId: string | null participantId: string | null
developerId: string | null
leadId: string | null leadId: string | null
conversationId: string | null conversationId: string | null
title: string title: string
@@ -17,6 +18,10 @@ export interface CalendarEvent {
status: EventStatus status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { 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 lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string createdAt: string
} }
+35 -35
View File
@@ -12,6 +12,7 @@ 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 { spawn } from "node:child_process"
import crypto from "node:crypto"
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))
@@ -44,6 +45,8 @@ 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")
@@ -130,19 +133,22 @@ async function scrapeFacebook() {
const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}` const urlPath = `/scrape/facebook?force=true${profilePath ? `&profile_path=${encodeURIComponent(profilePath)}` : ""}`
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)
let done = false
const req = http.request({ hostname: parsed.hostname, port: parsed.port || 3008, path: urlPath, method: "POST", timeout: 60000 }, (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", () => { done = true; resolve(data) })
res.on("error", reject) res.on("error", (e) => { if (!done) { done = true; reject(e) } })
}) })
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) }) req.on("timeout", () => { if (!done) { done = true; req.destroy(); reject(new Error("scraper timeout")) } })
req.on("error", reject) req.on("error", (e) => { if (!done) { done = true; reject(e) } })
req.end() req.end()
}) })
const data = JSON.parse(body) const data = JSON.parse(body)
return data return data
} catch (e) { } catch (e) {
console.error("scrapeFacebook error:", e.message)
return null return null
} }
} }
@@ -195,6 +201,7 @@ Provide concise, actionable sales advice. When asked about a specific job catego
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(60000),
body: JSON.stringify({ body: JSON.stringify({
model: MODEL, model: MODEL,
messages: [ messages: [
@@ -313,18 +320,20 @@ const server = http.createServer(async (req, res) => {
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 (port 3008) // 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("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject) r.on("error", reject)
}) })
results.scraper = true results.scraper = true
} catch { results.scraper = false } } catch { results.scraper = false }
// Check frontend (port 3006) // 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("timeout", () => { r.destroy(); reject(new Error("timeout")) })
r.on("error", reject) r.on("error", reject)
}) })
results.frontend = true results.frontend = true
@@ -368,8 +377,8 @@ const server = http.createServer(async (req, res) => {
let selectedBrowser = process.env.SELECTED_BROWSER || "" let selectedBrowser = process.env.SELECTED_BROWSER || ""
try { try {
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) }) await fetch(`${SCRAPER_URL}/health`, { signal: AbortSignal.timeout(2000) })
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json() const profiles = await (await fetch(`${SCRAPER_URL}/setup/profile`, { signal: AbortSignal.timeout(5000) })).json()
for (const [b, p] of Object.entries(profiles)) { for (const [b, p] of Object.entries(profiles)) {
if (p) browsers[b] = { path: p } if (p) browsers[b] = { path: p }
} }
@@ -377,7 +386,7 @@ const server = http.createServer(async (req, res) => {
const detectedList = Object.entries(browsers).filter(([, v]) => v.path) const detectedList = Object.entries(browsers).filter(([, v]) => v.path)
for (const [b, v] of detectedList) { for (const [b, v] of detectedList) {
try { try {
const r = await fetch("http://127.0.0.1:3008/setup/check-login", { const r = await fetch(`${SCRAPER_URL}/setup/check-login`, {
method: "POST", headers: { "Content-Type": "application/json" }, method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ browser: b, profile_path: v.path }), body: JSON.stringify({ browser: b, profile_path: v.path }),
signal: AbortSignal.timeout(20000), signal: AbortSignal.timeout(20000),
@@ -536,38 +545,29 @@ const server = http.createServer(async (req, res) => {
// Accepts { message, user_id?, user_role? } and returns AI response. // Accepts { message, user_id?, user_role? } and returns AI response.
// user_role must be "sales", "admin", or "super_admin" if provided. // 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 chunks = [] const chunks = []
req.on("data", c => chunks.push(c)) req.on("data", c => chunks.push(c))
req.on("end", () => { req.on("end", async () => {
const rawBody = Buffer.concat(chunks).toString()
try { try {
const rawBody = Buffer.concat(chunks).toString()
const body = JSON.parse(rawBody) const body = JSON.parse(rawBody)
processRequest(req, res, body, startTime) const { message, user_id, user_role } = body
} catch { if (!message) {
sendJSON(res, 400, { error: "Invalid JSON" }) return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
sendJSON(res, 200, { response })
} catch (e) {
if (!res.headersSent) sendJSON(res, 500, { error: e.message })
} }
}) })
return return
} }
// Separate handler for /ai/chat (defined here due to hoisting within the IIFE)
async function processRequest(req, res, body, startTime) {
const { message, user_id, user_role } = body
if (!message) {
return sendJSON(res, 400, { error: "message is required" })
}
const validRoles = ["sales", "admin", "super_admin"]
if (user_role && !validRoles.includes(user_role)) {
return sendJSON(res, 403, { error: "Forbidden" })
}
const response = await handleChat(message, user_id || "", user_role || "sales")
return sendJSON(res, 200, { response })
}
// 404 fallback // 404 fallback
sendJSON(res, 404, { error: "Not found" }) sendJSON(res, 404, { error: "Not found" })
} catch (err) { } catch (err) {
File diff suppressed because it is too large Load Diff
+128 -26
View File
@@ -1,40 +1,142 @@
# 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. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
### Lead Categories
Two categories, selectable when starting a scrape:
**Website Creation:**
- Target: people explicitly REQUESTING a website built/designed/created for them
- Keywords: "website", "web developer", "web design", "build a site", "who can build", etc.
- Request terms: "looking for", "need a", "need someone", "hire a", "recommend", "anyone know"
- Strict reject: service offers, SEO/marketing requests, learning-to-code, portfolio showcases, hiring posts, existing-website issues, geographic noise
**Tutoring:**
- Target: people explicitly REQUESTING a tutor, teacher, or lessons for themselves or their child
- Keywords: "tutor", "tutoring", "lessons for", "homework help", "private tutor", "extra classes"
- Request terms: same as website category — must co-occur with a target keyword
- Strict reject: people offering tutoring, educational products, homeschool programs, free trials, general study tips
### Multi-Language Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 7 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words - 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
View File
@@ -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"}
+18 -3
View File
@@ -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;
-- ============================================================================ -- ============================================================================
@@ -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,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;
+31
View File
@@ -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;
+16
View File
@@ -66,5 +66,21 @@ BEGIN;
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ===' \echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql \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
View File
@@ -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
View File
@@ -12,6 +12,22 @@ const nextConfig: NextConfig = {
}, },
], ],
}, },
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
...(process.env.NODE_ENV === "production"
? [{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" }]
: []),
],
},
]
},
} }
export default nextConfig export default nextConfig
+1535 -28
View File
File diff suppressed because it is too large Load Diff
+18 -8
View File
@@ -13,14 +13,22 @@
"dev:rust": "node ai-server/index.mjs", "dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs", "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",
@@ -42,7 +50,6 @@
"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",
"devenv": "^1.0.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"jose": "^6.2.3", "jose": "^6.2.3",
@@ -63,17 +70,20 @@
"zod": "^3.24.2" "zod": "^3.24.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@next/swc-win32-x64-msvc": "^15.0.4",
"@types/nodemailer": "^8.0.1", "@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", "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"
} }
} }
+122 -23
View File
@@ -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,103 @@ 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. **4-phase language pipeline** (English → Afrikaans → Xhosa → Zulu):
- **Phase 1 (English)**: User's selected query + 2-3 supplementary English searches from the English search pool. First query gets full human-like scroll, rest use quick search. This phase does the heavy lifting.
- **Phase 2 (Afrikaans)**: 2 Afrikaans queries targeting Afrikaans-speaking communities.
- **Phase 3 (isiXhosa)**: 2 Xhosa queries targeting Xhosa-speaking communities.
- **Phase 4 (isiZulu)**: 2 Zulu queries targeting Zulu-speaking communities.
- After all phases: pipeline check (date filter 2 days → AI + keyword classification → sort by freshness). Newest leads ranked first.
- Each phase extracts posts, deduplicates against all prior phases, then passes through a stealth delay (5-12s + mouse idle) before the next phase.
4. **Quick searches** — load page, double-scroll, extract visible posts (~12-18s each). Scroll-back behavior (35% chance to scroll up) and random return-to-top (25% chance) for stealth.
5. **Date filter** — only posts within **2 days** are considered. Anything older is discarded. Fresh leads only.
6. **Stealth mechanics**:
- Random viewport dimensions (1280×800 to 1920×1080) — never the same size twice
- Variable delays between searches (5-12 seconds) with mouse idle actions mixed in
- Human-like scroll patterns: scroll down, pause, sometimes scroll back up, sometimes return to top
- Canvas/WebGL/audio fingerprint spoofing via injected init scripts
- Random decoy page visits (e.g., Facebook Groups) between searches
- Profile directory is temp-copied and cleaned up after each scrape
- Detection signal monitoring (checkpoint, login pages, security challenges)
7. **2-pass classification (dead-accurate)**:
- **Pass 1 (AI)**: Ollama classifies each post as LEAD or NOT using a strict prompt per category. This is the primary filter and most accurate.
- **Pass 2 (Keyword)**: Only posts matching BOTH a target term AND a request term are kept. Requires multi-word phrases — standalone words like "need", "want", "help" are NOT used as they cause false positives. Aggressive reject list catches service offers, self-promotions, portfolio posts, learning-requests, and existing-site issues.
- **No loose fill**: Unlike the old approach, there is NO third pass that accepts posts matching EITHER term. Every returned lead has passed both AI and/or strict keyword validation. If fewer than 5 posts pass, that means only genuine leads are returned — no noise to pad the count.
8. **Scrape timing** — 3-6 minutes for a complete run. Returns 5-10 leads with high confidence.
## Self-Improvement Protocol ### 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 Pipeline (Phase Order)
4 South African languages in structured phases:
- **Phase 1 (English)**: primary query + supplementary English searches
- **Phase 2 (Afrikaans)**: 2 queries targeting Afrikaans speakers
- **Phase 3 (isiXhosa)**: 2 queries targeting Xhosa speakers
- **Phase 4 (isiZulu)**: 2 queries targeting Zulu speakers
### Output Format
Each lead returned includes:
- `title` — post preview text
- `author` — poster's name (may include location in name)
- `content` — extracted post text
- `url` — direct link to the post
- `date` — when posted (filtered within 2 days)
- `category` — "website" or "tutor"
Target is 5-10 dead-accurate leads per scrape. Quality over quantity — no loose padding.
### Configuration via Env Vars
- `SELECTED_BROWSER``firefox` (default), `chrome`, `opera`, `edge`, or `auto`
- `FX_PROFILE`, `CHROME_PROFILE`, `OPERA_PROFILE`, `EDGE_PROFILE` — browser profile paths
- `AI_PORT`, `AI_HOST` — AI server bind (default `3001`, `0.0.0.0`)
- `SCRAPER_URL` — scraper URL (default `http://127.0.0.1:3008`)
- `FRONTEND_URL` — frontend URL (default `http://127.0.0.1:3006`)
- `NEXT_PUBLIC_SCRAPER_URL` — frontend-facing scraper URL
- `OLLAMA_BASE_URL` — Ollama URL (default `http://localhost:11434`)
- `AI_MODEL` — Ollama model (default `llama3.2:3b`)
- `CLASSIFY_MODEL` — model for lead classification (default `dolphin-llama3:8b`)
## How to Start Scraping
1. Ensure all 3 services are running (ports 3001, 3006, 3008) and Ollama is on 11434
2. Open the frontend at `http://localhost:3006`
3. Select a job category (Website Creation or Tutoring)
4. Click "Search Facebook" — the scraper runs and returns leads
5. Leads are saved in the CRM for follow-up
## Sales Tips
- Cold emails should be under 150 words
- Follow up within 48 hours
- Personalise every outreach with the prospect's name and company
- Use open-ended questions in discovery calls
- Always ask for the next step before ending a call
- For website leads: mention specific pages or features they requested
- For tutoring leads: reference the subject and age group they mentioned
## Job Targeting
- Developers respond best to technical value props
- Marketing managers care about ROI and metrics
- C-level executives want brevity and business impact
- Parents hiring tutors: empathy and qualifications matter most
## Response Rules ## Response Rules
- Be direct and actionable — no fluff, no AI disclaimers - Be direct and actionable — no fluff, no AI disclaimers
@@ -41,3 +129,14 @@ Proxies and user agents are defined as constants at the top of `main.rs`.
- Never mention being an AI or language model - 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
+7 -6
View File
@@ -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,
}; };
@@ -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))
+346
View File
@@ -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)
})
+396
View File
@@ -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;
}
+92
View File
@@ -0,0 +1,92 @@
// ── TypeScript Error Parser ──────────────────────────────────────────
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
importPath: string;
/** The file that contains the failing import */
importerFile: string;
/** Line number in importer file */
line: number;
/** Column number */
column: number;
/** Full error message for context */
message: string;
/** Whether this is an internal (@/...) module we can generate */
isInternal: boolean;
/** The normalized internal path, e.g. "data/stickers" */
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
// File: src/components/chats/media-picker.tsx:10:1
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
let match;
while ((match = errorRegex.exec(tscOutput)) !== null) {
const importPath = match[1];
const importerFile = match[2] || "";
const line = parseInt(match[3] || "0", 10);
const column = parseInt(match[4] || "0", 10);
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
let altMatch;
let altImporter = importerFile;
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
if (altMatch[1] === importPath) {
altImporter = altMatch[2];
break;
}
}
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
let internalPath: string | undefined;
if (isInternal) {
internalPath = importPath.replace(/^[@~]\//, "");
}
results.push({
importPath,
importerFile: altImporter,
line,
column,
message: `Cannot find module '${importPath}'`,
isInternal,
internalPath,
});
}
// Deduplicate by importPath + importerFile
const seen = new Set<string>();
return results.filter((m) => {
const key = `${m.importPath}|${m.importerFile}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Filter to only internal (@/...) modules we can auto-generate */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by internal path */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
if (!m.internalPath) continue;
const existing = groups.get(m.internalPath) || [];
existing.push(m);
groups.set(m.internalPath, existing);
}
return groups;
}
+32 -3
View File
@@ -1,12 +1,41 @@
// ── Dependency Check ─────────────────────────────────────────────────
// Verifies all packages listed in package.json are installed.
// Auto-runs npm install if any are missing — zero manual setup steps.
import { readFileSync, existsSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { execSync } from "node:child_process"
import { platform } from "node:os"
const __dirname = dirname(fileURLToPath(import.meta.url))
const root = resolve(__dirname, "..")
try {
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
const missing = Object.keys(allDeps).filter(name => {
const pkgDir = resolve(root, "node_modules", name)
return !existsSync(resolve(pkgDir, "package.json"))
})
if (missing.length > 0) {
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
console.log("Installing...\n")
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
console.log("Dependencies installed\n")
}
} catch {
// If package.json read or resolution fails, skip silently
}
// ── Port Precheck ────────────────────────────────────────────────── // ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008. // Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and // These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively. // Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors. // Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008] const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") { if (platform() === "win32") {
+184
View File
@@ -0,0 +1,184 @@
// ── Automated Database Migration Runner ──────────────────────────────
// Reads .env.local for DATABASE_URL, tracks applied migrations in
// a _migrations table, and runs unapplied .sql files via psql.
// Runs automatically on npm run dev and npm run setup.
// ============================================================================
import { readFileSync, existsSync } from "node:fs"
import { execSync } from "node:child_process"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const __dirname = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(__dirname, "..")
// ── Load .env.local into process.env ─────────────────────────────────
const envPath = resolve(ROOT, ".env.local")
if (existsSync(envPath)) {
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eq = trimmed.indexOf("=")
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1)
}
if (!process.env[key]) process.env[key] = value
}
}
const DATABASE_URL = process.env.DATABASE_URL
if (!DATABASE_URL) {
console.log(" ~ DATABASE_URL not set — skipping migrations")
process.exit(0)
}
// ── Find psql ────────────────────────────────────────────────────────
function findPsql() {
try {
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
return "psql"
} catch {}
const candidates = [
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
]
for (const p of candidates) {
if (existsSync(p)) return `"${p}"`
}
return null
}
const PSQL = findPsql()
if (!PSQL) {
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
process.exit(0)
}
// ── Database connection (for tracking table) ─────────────────────────
const require = createRequire(import.meta.url)
const { Pool } = require("pg")
let pool
try {
pool = new Pool({
connectionString: DATABASE_URL,
max: 1,
connectionTimeoutMillis: 5000,
idleTimeoutMillis: 10000,
})
await pool.query("SELECT 1")
} catch {
console.log(" ~ Database not available — skipping migrations")
process.exit(0)
}
// ── Migration logic ──────────────────────────────────────────────────
async function runMigrations() {
// 1. Create tracking table if not exists
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL UNIQUE,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// 2. Determine file order from run_all.sql (authoritative)
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
const runAllContent = readFileSync(runAllPath, "utf-8")
const fileOrder = []
for (const line of runAllContent.split("\n")) {
const match = line.match(/\\i\s+(\S+\.sql)/)
if (match) fileOrder.push(match[1])
}
if (fileOrder.length === 0) {
console.log(" ~ No migration files found in run_all.sql")
return
}
// 3. Get already-applied files
let appliedSet
try {
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
appliedSet = new Set(applied.map((r) => r.filename))
} catch {
appliedSet = new Set()
}
// 4. Build psql command base
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
// 5. Run unapplied files in order.
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
// If the error is "already exists", the file was already applied before
// tracking existed — mark it as applied and continue.
// Other errors are genuine failures — abort.
const ALREADY_APPLIED_PATTERNS = [
"already exists",
"duplicate key",
"cannot insert multiple commands into a prepared statement",
]
let count = 0
for (const file of fileOrder) {
if (appliedSet.has(file)) continue
const filePath = resolve(ROOT, "database", "migrations", file)
if (!existsSync(filePath)) {
console.error(` ✗ Migration file not found: ${file}`)
process.exit(1)
}
let stderr = ""
try {
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
timeout: 30000,
encoding: "utf-8",
maxBuffer: 10 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
})
// psql outputs notices/warnings to stderr even on success
stderr = result.stderr || ""
} catch (err) {
const msg = err.stderr || err.stdout || err.message
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
if (isAlreadyApplied) {
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
console.log(` ~ Already applied: ${file}`)
count++
continue
}
console.error(` ✗ Migration failed: ${file}`)
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
process.exit(1)
}
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
console.log(` ✓ Applied: ${file}`)
count++
}
if (count === 0) {
console.log(" ~ All migrations up to date")
}
}
// ── Run ──────────────────────────────────────────────────────────────
try {
await runMigrations()
} finally {
await pool.end()
}
+145 -17
View File
@@ -5,17 +5,22 @@
// 2. pip install -r requirements.txt (Python dependencies) // 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers) // 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists // 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). // All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs" import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os" import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const SEP = platform() === "win32" ? "&" : ";" const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
// Auto-detect Python executable (python vs python3)
function detectPython() { function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"] const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) { for (const cmd of candidates) {
@@ -28,7 +33,6 @@ function detectPython() {
process.exit(1) process.exit(1)
} }
// Auto-detect pip (pip vs pip3), fall back to python -m pip
function detectPip(python) { function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"] const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) { for (const cmd of candidates) {
@@ -40,43 +44,167 @@ function detectPip(python) {
return `${python} -m pip` return `${python} -m pip`
} }
const PY = detectPython() function run(cmd, label, opts = {}) {
const PIP = detectPip(PY)
function run(cmd, label) {
console.log(`\n── ${label} ──`) console.log(`\n── ${label} ──`)
try { try {
execSync(cmd, { stdio: "inherit", timeout: 120000 }) execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) { } catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`) console.error(` ✗ Failed: ${e.message}`)
process.exit(1) 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") console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies // 1. Node dependencies
run("npm install", "Installing Node.js dependencies") run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory) // 2. Python dependencies
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies") run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback) // 3. Playwright browsers
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers") run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist // 4. .env file
if (!existsSync(".env.local")) { if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──") console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local") copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings") console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else { } else {
console.log("\n── .env.local already exists, skipping ──") console.log("\n── .env.local already exists, skipping ──")
} }
// 5. Remaining manual steps // 5. Final summary
console.log("\n── Next steps ──") console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'") 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(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings") console.log(" 3. Edit .env.local with your settings")
console.log(" 4. Run: npm run dev") console.log(" 4. Run: npm run db:migrate (apply database migrations)")
console.log(" 5. Run: npm run dev")
console.log("\n=== Setup complete! ===") console.log("\n=== Setup complete! ===")
+5 -2
View File
@@ -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 })
+103 -66
View File
@@ -1,85 +1,122 @@
"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] || job.job_title
aiChatRef.current?.addAssistantMessage(`🔍 Searching Facebook for **${job.job_title}** leads...`)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 360000)
const statusId = setTimeout(() => {
aiChatRef.current?.addAssistantMessage("⏳ Still searching Facebook (this can take up to 5 minutes)...")
}, 45000)
const scrapBase = process.env.NEXT_PUBLIC_SCRAPER_URL || "http://localhost:3008"
try {
const res = await fetch(`${scrapBase}/scrape/facebook?force=true&query=${encodeURIComponent(keyword)}`, { method: "POST", signal: controller.signal })
clearTimeout(timeoutId)
clearTimeout(statusId)
const data = await res.json()
if (data.success && data.leads?.length > 0) {
const leadLines = data.leads
.filter(Boolean)
.map((lead: Record<string, string>, i: number) =>
`**${i + 1}.** ${lead?.author || "Unknown"}\n> ${(lead?.content || "").slice(0, 300)}\n> 🔗 ${lead?.url || "(no link available)"}`
)
const leadsText = leadLines.join("\n\n")
aiChatRef.current?.addAssistantMessage(`✅ Found **${data.leads.length}** leads:\n\n${leadsText}`)
} else {
const reason = data.error || data.flag_reason || "No leads found this time"
aiChatRef.current?.addAssistantMessage(`⚠️ ${reason}`)
}
} catch (err: any) {
clearTimeout(timeoutId)
clearTimeout(statusId)
const msg = err.name === "AbortError"
? "❌ Scraper timed out after 5 minutes. Try again or check the scraper service."
: `${err instanceof Error ? err.message : "Search failed"}`
aiChatRef.current?.addAssistantMessage(msg)
} finally {
setSearching(false)
}
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
}, [])
return ( 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>
) )
+189 -54
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") 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 [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .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]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,37 +196,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
const handleSave = async () => { const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) { if (!formTitle.trim()) {
toast.error("Title, date, and time are required") toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return return
} }
setFormSaving(true) setFormSaving(true)
try { try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +268,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +295,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -279,7 +314,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to save") if (!res.ok) throw new Error("Failed to save")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) { if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null }) setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
} }
@@ -579,7 +614,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +643,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <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" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <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" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</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="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +673,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -663,6 +712,38 @@ export default function CalendarPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </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"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <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" /> <Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -690,7 +771,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +792,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </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 */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +839,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p> <p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </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 */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +905,32 @@ export default function CalendarPage() {
</div> </div>
</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) */} {/* 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="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"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +965,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <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")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+5 -7
View File
@@ -15,11 +15,12 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
Search, Send, Phone, Video, 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, Undo2, CalendarDays, Loader2, FolderOpen, Mail, CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react" } from "lucide-react"
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions" 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,
@@ -792,7 +793,7 @@ const formatPreviewContent = (content: string) => {
}) })
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"
@@ -920,12 +921,9 @@ const formatPreviewContent = (content: string) => {
</div> </div>
</div> </div>
<div className="flex items-center gap-1 shrink-0"> <div className="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Voice calling coming soon")}> <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={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
</Button> </Button>
@@ -1044,7 +1042,7 @@ const formatPreviewContent = (content: string) => {
{stickerData.emoji ? ( {stickerData.emoji ? (
<span className="text-7xl block text-center">{stickerData.emoji}</span> <span className="text-7xl block text-center">{stickerData.emoji}</span>
) : ( ) : (
<div className="w-full" dangerouslySetInnerHTML={{ __html: stickerData.url.replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} /> <div className="w-full" dangerouslySetInnerHTML={{ __html: sanitizeSvg(stickerData.url).replace(/<svg /, '<svg style="width:100%;height:auto;max-height:200px" ') }} />
)} )}
</div> </div>
) : voiceUrl ? ( ) : voiceUrl ? (
+11 -22
View File
@@ -24,12 +24,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DashboardStats } from "@/types" import { DashboardStats } from "@/types"
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
import { StarField } from "@/components/dashboard/star-field"
import { useWebsiteTheme } from "@/providers/website-theme-provider" import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() { export default function DashboardPage() {
const { websiteTheme: theme } = useWebsiteTheme() 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)
@@ -67,22 +65,12 @@ export default function DashboardPage() {
return ( return (
<div className="space-y-6 relative"> <div className="space-y-6 relative">
{theme === "starforce" && <StarField />}
<div className="relative z-[1] space-y-6"> <div className="relative z-[1] space-y-6">
<div> <div>
<PageHeader <PageHeader
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>} title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
description="Overview of your sales pipeline" description="Overview of your sales pipeline"
> >
{theme === "starforce" && (
<span
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
style={{ fontFamily: "'Cormorant Garamond', serif" }}
>
Trust the Force, Find your Path
</span>
)}
{theme === "starforce" && <CrossedLightsabers />}
<Select value={period} onValueChange={setPeriod}> <Select value={period} onValueChange={setPeriod}>
<SelectTrigger className="h-9 w-[160px]"> <SelectTrigger className="h-9 w-[160px]">
<ListFilter className="mr-2 h-4 w-4" /> <ListFilter className="mr-2 h-4 w-4" />
@@ -98,7 +86,7 @@ export default function DashboardPage() {
</PageHeader> </PageHeader>
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats {stats
@@ -109,7 +97,7 @@ export default function DashboardPage() {
} }
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} /> <LeadStatusChart data={stats?.statusDistribution ?? []} />
@@ -118,12 +106,13 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} /> <RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div> </div>
{/* Daily Bugle watermark */} {websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]"> <div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider"> <span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE DAILY BUGLE
</span> </span>
</div> </div>
)}
</div> </div>
) )
} }
+10 -3
View File
@@ -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>
} }
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server"
import { chatWithAI } from "@/lib/ai"
import { getSessionUser } from "@/lib/auth"
export async function POST(request: NextRequest) {
try {
const user = await getSessionUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { message } = await request.json()
if (!message || typeof message !== "string") {
return NextResponse.json({ error: "Message is required" }, { status: 400 })
}
const sessionCookie = request.cookies.get("session")
const jwtToken = sessionCookie?.value
if (!jwtToken) {
return NextResponse.json({ error: "No session token" }, { status: 401 })
}
const response = await chatWithAI(message, jwtToken)
return NextResponse.json({ response })
} catch (error: any) {
console.error("AI chat error:", error)
return NextResponse.json({ error: error.message || "AI service error" }, { status: 500 })
}
}
+57
View File
@@ -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 })
}
}
-11
View File
@@ -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 })
}
+33 -16
View File
@@ -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" } }
) )
} }
} }
+11 -7
View File
@@ -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" } }
) )
} }
} }
+26 -17
View File
@@ -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 AND deleted_at IS NULL 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 AND deleted_at IS NULL 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
) )
@@ -80,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, 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: {
+111 -78
View File
@@ -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" },
+5 -1
View File
@@ -8,8 +8,11 @@ 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 u.id, u.first_name, u.last_name, u.email `SELECT u.id, u.first_name, u.last_name, u.email,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u 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 WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`, ORDER BY u.first_name ASC`,
[user.id], [user.id],
@@ -19,6 +22,7 @@ export async function GET() {
id: r.id, id: r.id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.email, email: r.email,
role: r.role_display || r.role_name || "",
})) }))
return NextResponse.json({ users }) return NextResponse.json({ users })
+37 -10
View File
@@ -9,11 +9,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json() const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
const existing = await query( const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type, `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.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, 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 p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e FROM scheduled_events e
@@ -31,8 +31,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const old = existing.rows[0] const old = existing.rows[0]
const isCreator = old.user_id === user.id const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id const isParticipant = old.participant_id === user.id
const isDeveloper = old.developer_id === user.id
if (!isCreator && !isParticipant) { if (!isCreator && !isParticipant && !isDeveloper) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
} }
@@ -42,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
(startTime !== undefined && startTime !== old.start_time) || (startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) || (endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) || (durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id) (participantId !== undefined && participantId !== old.participant_id) ||
(developerId !== undefined && developerId !== old.developer_id)
)) { )) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 }) return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
} }
@@ -53,14 +55,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
description = COALESCE($2, description), description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes), participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type), event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time), start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
end_time = COALESCE($6, end_time), end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
duration_minutes = COALESCE($7, duration_minutes), duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
status = COALESCE($8, status), status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id), 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() updated_at = NOW()
WHERE id = $10 WHERE id = $14
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`, 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, title || null,
description ?? null, description ?? null,
@@ -71,6 +77,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
durationMinutes ?? null, durationMinutes ?? null,
status || null, status || null,
participantId ?? null, participantId ?? null,
developerId ?? null,
clientName ?? null,
clientEmail ?? null,
clientPhone ?? null,
id, id,
], ],
user.id, user.id,
@@ -78,6 +88,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0] 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 || 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.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status r.status !== old.status
@@ -108,12 +129,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
) )
const creatorInfo = creatorResult.rows[0] 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 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({ return NextResponse.json({
event: { event: {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -126,7 +149,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
status: r.status, 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" }, 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, participant: participantInfo,
developer: developerInfo,
lead: null, lead: null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
}, },
}) })
+106 -62
View File
@@ -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 { sendEventConfirmation } from "@/lib/email" import { sendEventConfirmation } from "@/lib/email"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
@@ -13,27 +13,33 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end") const end = searchParams.get("end")
const status = searchParams.get("status") const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id, let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at, 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, 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, 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, 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, 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 l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e FROM scheduled_events e
JOIN users u ON u.id = e.user_id JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_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 leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_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 roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id` LEFT JOIN roles pr2 ON pr2.id = pr.role_id
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
const params: unknown[] = [] const params: unknown[] = []
let idx = 1 let idx = 1
if (user.role !== "super_admin") { if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}` sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id) params.push(user.id)
idx++ idx++
} else { } else {
@@ -41,13 +47,13 @@ export async function GET(request: NextRequest) {
} }
if (start) { if (start) {
sql += ` AND e.start_time >= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
params.push(start) params.push(start)
idx++ idx++
} }
if (end) { if (end) {
sql += ` AND e.start_time <= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
params.push(end) params.push(end)
idx++ idx++
} }
@@ -66,6 +72,7 @@ export async function GET(request: NextRequest) {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -78,7 +85,11 @@ export async function GET(request: NextRequest) {
status: r.status, 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 }, 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, 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, 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, createdAt: r.created_at,
})) }))
@@ -95,58 +106,98 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { const {
participantId, leadId, conversationId, participantId, developerId, leadId, conversationId,
title, description, eventType, title, description, eventType,
startTime, endTime, durationMinutes, startTime, endTime, durationMinutes,
clientName, clientEmail, clientPhone,
} = await request.json() } = await request.json()
if (!title || !startTime) { if (!title) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 }) return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
} }
const result = await query( // Single transaction for all DB operations
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes) const result = await transaction(async (client) => {
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) // 1. Insert event
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`, 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)
user.id, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
participantId || null, 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`,
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, user.id, participantId || null, developerId || null, leadId || null, conversationId || null,
"event_scheduled", title, description || null, eventType || "website_creation", startTime || null,
`Meeting scheduled: ${title}`, eventType === "website_creation" ? null : (endTime || null),
`${user.firstName} ${user.lastName} scheduled a ${eventType || "meeting"} with you`, eventType === "website_creation" ? null : (durationMinutes || null),
`/calendar`, clientName || null, clientEmail || null, clientPhone || null,
r.id,
"scheduled_event",
], ],
) )
} const r = ins.rows[0]
const participantResult = participantId ? await query( // 2. Auto-update lead stage if website creation event
`SELECT email, first_name, last_name FROM users WHERE id = $1`, if (r.event_type === "website_creation" && leadId) {
[participantId], const stageResult = await client.query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
) : null if (stageResult.rows.length > 0) {
const participant = participantResult?.rows[0] || null 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({ sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`, creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email, creatorEmail: user.email,
@@ -162,23 +213,16 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ return NextResponse.json({
event: { event: {
id: r.id, id: r.id, userId: r.user_id, participantId: r.participant_id, developerId: r.developer_id,
userId: r.user_id, leadId: r.lead_id, conversationId: r.conversation_id,
participantId: r.participant_id, title: r.title, description: r.description, participantNotes: r.participant_notes,
leadId: r.lead_id, eventType: r.event_type, startTime: r.start_time, endTime: r.end_time,
conversationId: r.conversation_id, durationMinutes: r.duration_minutes, status: r.status, createdAt: r.created_at,
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 }, creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null, participant: participant ? { id: participantId, name: `${participant.first_name} ${participant.last_name}`, email: participant.email } : null,
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null, lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
createdAt: r.created_at, clientName: r.client_name || null, clientEmail: r.client_email || null, clientPhone: r.client_phone || null,
}, },
}, { status: 201 }) }, { status: 201 })
} catch (error) { } catch (error) {
+63 -30
View File
@@ -1,8 +1,8 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from "next/server"
import { getSessionUser } from "@/lib/auth" import { getSessionUser } from "@/lib/auth"
const TENOR_API_KEY = process.env.TENOR_API_KEY || "" const GIPHY_API_KEY = process.env.GIPHY_API_KEY
const TENOR_BASE = "https://tenor.googleapis.com/v2" const GIPHY_BASE = "https://api.giphy.com/v1/gifs"
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
@@ -12,45 +12,78 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const q = searchParams.get("q") || "" const q = searchParams.get("q") || ""
const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50) const limit = Math.min(parseInt(searchParams.get("limit") || "20", 10), 50)
const pos = searchParams.get("pos") || "" const pos = parseInt(searchParams.get("pos") || "0", 10) || 0
if (!TENOR_API_KEY) { if (!GIPHY_API_KEY) {
return NextResponse.json({ console.error("Missing GIPHY_API_KEY environment variable")
results: [], return NextResponse.json({ results: [], noKey: true })
error: "TENOR_API_KEY not configured",
noKey: true,
})
} }
const endpoint = q let data: any
? `${TENOR_BASE}/search?q=${encodeURIComponent(q)}&key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
: `${TENOR_BASE}/featured?key=${TENOR_API_KEY}&limit=${limit}&media_filter=minimal`
const url = pos ? `${endpoint}&pos=${pos}` : endpoint if (q) {
const url = `${GIPHY_BASE}/search?api_key=${GIPHY_API_KEY}&q=${encodeURIComponent(q)}&limit=${limit}&offset=${pos}&rating=g`
const res = await fetch(url, { signal: AbortSignal.timeout(8000) })
if (!res.ok) {
const errBody = await res.text()
console.error("GIPHY API search error:", res.status, errBody)
return NextResponse.json({ results: [], error: `GIPHY error: ${errBody}` }, { status: 502 })
}
data = await res.json()
} else {
const trendingUrl = `${GIPHY_BASE}/trending?api_key=${GIPHY_API_KEY}&limit=${limit}&offset=${pos}&rating=g`
const trendingRes = await fetch(trendingUrl, { signal: AbortSignal.timeout(8000) })
const res = await fetch(url, { signal: AbortSignal.timeout(8000) }) if (trendingRes.ok) {
if (!res.ok) { data = await trendingRes.json()
return NextResponse.json({ results: [], error: "Tenor API error" }, { status: 502 }) 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 data = await res.json() const dims = item.images?.original
const results = (data.results || []).map((item: any) => {
const media = item.media_formats?.gif || item.media_formats?.tinygif || {}
const preview = item.media_formats?.tinygif || item.media_formats?.gif || {}
return { return {
id: item.id, id: item.id,
title: item.title || "", title: item.title || "",
url: media.url || "", url: dims?.url || "",
previewUrl: preview.url || "", previewUrl: item.images?.fixed_width?.url || "",
width: media.dims?.[0] || 200, width: dims?.width ? parseInt(dims.width, 10) : 200,
height: media.dims?.[1] || 200, height: dims?.height ? parseInt(dims.height, 10) : 200,
} }
}) })
return NextResponse.json({ results, next: data.next || "" }) const nextOffset = pos + results.length
} catch (error) { const total = data.pagination?.total_count || 0
console.error("GIF API error:", error) const hasMore = total ? nextOffset < total : results.length === limit
return NextResponse.json({ results: [], error: "Failed to fetch GIFs" }, { status: 500 }) 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 })
} }
} }
+27 -3
View File
@@ -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
View File
@@ -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 })
+26 -8
View File
@@ -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 || "starforce" 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 || "starforce" 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 })
-3
View File
@@ -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
+10
View File
@@ -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 })
+40 -40
View File
@@ -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>
+73 -54
View File
@@ -2,6 +2,8 @@
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
@import "../styles/ai-assistant.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -76,72 +78,88 @@
} }
:root { :root {
--background: 220 14% 84%; --background: 60 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 71 20% 16%;
--card: 220 10% 91%; --card: 60 29% 97%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 71 20% 16%;
--popover: 220 14% 96%; --popover: 60 29% 97%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 71 20% 16%;
--primary: 120 100% 50%; --primary: 0 53% 54%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 220 14% 88%; --secondary: 210 47% 56%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 100%;
--muted: 220 14% 88%; --muted: 75 10% 93%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 75 10% 52%;
--accent: 220 14% 88%; --accent: 159 32% 43%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%; --destructive: 0 53% 54%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%; --border: 80 13% 82%;
--input: 214.3 31.8% 91.4%; --input: 80 13% 82%;
--ring: 221.2 83.2% 53.3%; --ring: 0 53% 54%;
--sidebar: 216 12% 92%; --sidebar: 80 17% 92%;
--sidebar-foreground: 0 0% 20%; --sidebar-foreground: 71 20% 16%;
--sidebar-primary: 0 91.2% 59.8%; --sidebar-primary: 0 53% 54%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 218 16% 87%; --sidebar-accent: 80 17% 92%;
--sidebar-accent-foreground: 0 0% 20%; --sidebar-accent-foreground: 71 20% 16%;
--sidebar-border: 220 11% 84%; --sidebar-border: 80 13% 82%;
--sidebar-ring: 0 76.3% 48%; --sidebar-ring: 0 53% 54%;
--radius: 0.5rem; --radius: 0.5rem;
--theme-primary: hsl(var(--primary)); --theme-primary: hsl(var(--primary));
--event-meeting: 217 91% 55%; --color-blue: 210 47% 56%;
--color-teal: 159 32% 43%;
--color-red-tint: 0 62% 95%;
--color-blue-tint: 208 54% 93%;
--color-teal-tint: 157 35% 91%;
--color-avatar: 102 21% 52%;
--event-call: 160 84% 39%; --event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%; --event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%; --event-website_creation: 263 70% 50%;
--event-other: 215 20% 55%;
} }
.dark { .dark {
--background: 0 0% 10%; /* ── Slate palette tokens ── */
--foreground: 210 40% 98%; --bg-page: 225 18% 9%;
--card: 0 0% 9%; --bg-sidebar: 227 25% 7%;
--card-foreground: 210 40% 98%; --bg-card: 224 24% 11%;
--popover: 222.2 84% 11%; --bg-card-inset: 224 24% 13%;
--popover-foreground: 210 40% 98%; --bg-nav-active: 221 43% 20%;
--primary: 0 100% 53%; --border: 228 21% 21%;
--border-strong: 220 69% 59%;
--text-primary: 226 30% 95%;
--text-secondary: 228 13% 59%;
--text-active: 221 100% 87%;
--accent-hover: 220 70% 66%;
/* ── Existing CSS variable overrides ── */
--background: 225 18% 9%;
--foreground: 226 30% 95%;
--card: 224 24% 11%;
--card-foreground: 226 30% 95%;
--popover: 224 24% 13%;
--popover-foreground: 226 30% 95%;
--primary: 220 69% 59%;
--primary-foreground: 222.2 47.4% 11.2%; --primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%; --secondary: 224 24% 13%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 226 30% 95%;
--muted: 217.2 32.6% 17.5%; --muted: 224 24% 13%;
--muted-foreground: 215 20.2% 65.1%; --muted-foreground: 228 13% 59%;
--accent: 217.2 32.6% 17.5%; --accent: 224 24% 13%;
--accent-foreground: 210 40% 98%; --accent-foreground: 226 30% 95%;
--destructive: 0 62.8% 30.6%; --destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%; --border: 228 21% 21%;
--input: 217.2 32.6% 17.5%; --input: 228 21% 21%;
--ring: 0 100% 53%; --ring: 220 69% 59%;
--sidebar: 0 0% 11%; --sidebar: 227 25% 7%;
--sidebar-foreground: 210 40% 98%; --sidebar-foreground: 226 30% 95%;
--sidebar-primary: 145 65% 50%; --sidebar-primary: 221 43% 20%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 221 100% 87%;
--sidebar-accent: 217.2 32.6% 17.5%; --sidebar-accent: 224 24% 13%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 226 30% 95%;
--sidebar-border: 217.2 32.6% 17.5%; --sidebar-border: 228 21% 21%;
--sidebar-ring: 0 100% 53%; --sidebar-ring: 220 69% 59%;
} }
.ocean { .ocean {
@@ -731,3 +749,4 @@ main {
.checkbox-text { color: rgba(232,232,239,0.38); } .checkbox-text { color: rgba(232,232,239,0.38); }
.footer-text { color: rgba(232,232,239,0.2); } .footer-text { color: rgba(232,232,239,0.2); }
+12 -12
View File
@@ -7,9 +7,9 @@ interface Props {
function ErrorPage({ message }: { message: string }) { function ErrorPage({ message }: { message: string }) {
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">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3"> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
{message} {message}
</h1> </h1>
</div> </div>
@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) {
} }
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">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3"> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
You&apos;re Invited! You&apos;re Invited!
</h1> </h1>
<p className="text-[#888888] text-sm mb-6"> <p className="text-[#8A9078] text-sm mb-6">
Someone wants to connect with you on our platform. Someone wants to connect with you on our platform.
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left"> <div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#888888] mb-1">Contact Number</p> <p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p> <p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
</div> </div>
<p className="text-xs text-[#888888] mb-6"> <p className="text-xs text-[#8A9078] mb-6">
Create an account to start making free voice calls to this person and others on the platform. Create an account to start making free voice calls to this person and others on the platform.
</p> </p>
<a <a
href="/register" href="/register"
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200" className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
> >
Create Account Create Account
</a> </a>
+7 -2
View File
@@ -4,8 +4,8 @@ import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider" import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import "./globals.css" import "./globals.css"
import "../../Web_Backgrounds/default-theme.css"
import "../../Web_Backgrounds/spidey-theme.css" import "../../Web_Backgrounds/spidey-theme.css"
import "../../Web_Backgrounds/starforce-theme.css"
const inter = Inter({ const inter = Inter({
variable: "--font-inter", variable: "--font-inter",
@@ -33,7 +33,12 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}> <body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange> <script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("crm-color-theme");if(t&&t!=="default")document.documentElement.classList.add(t);var w=localStorage.getItem("crm-website-theme");if(w==="spidey")document.documentElement.classList.add("theme-spidey")}catch(e){}})()`,
}}
/>
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
<WebsiteThemeProvider> <WebsiteThemeProvider>
{children} {children}
<Toaster /> <Toaster />
+21 -2
View File
@@ -1,9 +1,11 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
const WEBSITE_THEME_KEY = "crm-website-theme"
const waves = [ const waves = [
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" }, { a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" }, { a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
@@ -13,7 +15,20 @@ const waves = [
] ]
export default function LoginPage() { export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -214,7 +229,11 @@ export default function LoginPage() {
}) })
if (res.ok) { if (res.ok) {
router.push("/dashboard") localStorage.removeItem(WEBSITE_THEME_KEY)
const root = document.documentElement
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else { } else {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
+301 -99
View File
@@ -1,33 +1,155 @@
"use client" "use client"
import { useState, useRef, useEffect, Fragment } from "react" import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Send, Loader2, Bot, User, RefreshCw, AlertCircle } from "lucide-react" import { Bot, Terminal } from "lucide-react"
import { GifPicker } from "./gif-picker"
function linkifyText(text: string) { function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex) const parts = text.split(urlRegex)
return parts.map((part, i) => { return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) { if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a> return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-primary hover:text-primary/80">{part}</a>
} }
return <Fragment key={i}>{part}</Fragment> return <Fragment key={i}>{part}</Fragment>
}) })
} }
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
const trimmed = line.trim()
if (trimmed.startsWith("•") || trimmed.startsWith("-")) {
const content = trimmed.replace(/^[•\-]\s*/, "")
return (
<div key={i} className="flex items-start gap-2.5 my-1.5">
<span className="w-1.5 h-1.5 bg-primary rounded-sm inline-block mt-2 flex-shrink-0" />
<span>{linkifyText(content)}</span>
</div>
)
}
if (line === "") return <div key={i} className="h-2" />
return <p key={i} className="my-1">{linkifyText(line)}</p>
})
}
interface ChatMessage { interface ChatMessage {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
ts?: number
gif?: {
url: string
previewUrl: string
title: string
}
} }
export function AIChat() { interface AIChatProps {
onMessageSent?: (msg: string) => void
}
export interface AIChatHandle {
fillInput: (text: string) => void
addAssistantMessage: (content: string) => void
}
export const AIChat = forwardRef<AIChatHandle, AIChatProps>(({ onMessageSent }, ref) => {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [ollamaStatus, setOllamaStatus] = useState<boolean | null>(null) const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const [showGifPicker, setShowGifPicker] = useState(false)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const hasUserMessage = messages.some(m => m.role === "user")
const loadedFromStorage = useRef(false)
useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
setTimeout(() => textareaRef.current?.focus(), 50)
},
addAssistantMessage(content: string) {
setMessages((prev) => {
if (!prev.some((m) => m.role === "user")) {
return [
{ role: "user", content: "Search Facebook" },
{ role: "assistant", content },
]
}
return [...prev, { role: "assistant", content }]
})
},
}), [])
const handleGifSelect = useCallback((gif: { url: string; previewUrl: string; title: string }) => {
setMessages((prev) => [...prev, { role: "user", content: gif.title || "Sent a GIF", gif }])
setShowGifPicker(false)
}, [])
useEffect(() => { useEffect(() => {
if (showGifPicker) {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowGifPicker(false)
}
}
document.addEventListener("mousedown", handler)
return () => document.removeEventListener("mousedown", handler)
}
}, [showGifPicker])
const _WEEK_MS = 604800000
useEffect(() => {
const saved = localStorage.getItem("ai-chat-messages")
if (saved) {
try {
let parsed = JSON.parse(saved)
if (Array.isArray(parsed) && parsed.length > 0) {
const cutoff = Date.now() - _WEEK_MS
parsed = parsed.filter((m: any) => !m.ts || m.ts > cutoff)
if (parsed.length > 0) {
setMessages(parsed)
loadedFromStorage.current = true
}
}
} catch {}
} else {
loadedFromStorage.current = false
}
}, [])
useEffect(() => {
if (messages.length > 0) {
const now = Date.now()
localStorage.setItem("ai-chat-messages", JSON.stringify(messages.map(m => ({ ...m, ts: m.ts || now }))))
}
}, [messages])
const checkServer = useCallback(async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "ping" }),
})
if (res.status !== 503) {
setBootState("ready")
} else {
setTimeout(checkServer, 2000)
}
} catch {
setTimeout(checkServer, 2000)
}
}, [])
useEffect(() => {
checkServer()
if (loadedFromStorage.current) return
fetch("/api/ai/jobs") fetch("/api/ai/jobs")
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
@@ -56,35 +178,19 @@ export function AIChat() {
}, },
]) ])
}) })
}, []) }, [checkServer])
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
const checkOllama = async () => { const sendMessage = useCallback(async (text?: string) => {
try { const msg = (text || input).trim()
const res = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: "__ping__" }),
})
setOllamaStatus(res.status !== 503)
} catch {
setOllamaStatus(false)
}
}
useEffect(() => { checkOllama() }, [])
const sendMessage = async () => {
const msg = input.trim()
if (!msg || loading) return if (!msg || loading) return
setInput("") setInput("")
setError("") setError("")
onMessageSent?.(msg)
setMessages((prev) => [...prev, { role: "user", content: msg }]) setMessages((prev) => [...prev, { role: "user", content: msg }])
setLoading(true) setLoading(true)
@@ -96,8 +202,8 @@ export function AIChat() {
}) })
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json().catch(() => ({}))
throw new Error(data.error || "Failed to get response") throw new Error(data.error || `Error ${res.status}`)
} }
const data = await res.json() const data = await res.json()
@@ -112,90 +218,186 @@ export function AIChat() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [input, loading, onMessageSent])
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault()
sendMessage() sendMessage()
} }
}, [sendMessage])
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-5">
<p className="text-sm text-muted-foreground animate-pulse">Servers booting...</p>
<div className="h-[56px] w-[110px] bg-card border border-border rounded-xl flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="hsl(var(--primary))" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="hsl(var(--primary))" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="hsl(var(--primary))" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
<rect x="22" y="5" width="4" height="4" rx="1" style={{fill: "hsl(var(--background))"}}/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="hsl(var(--primary))"/>
<rect x="17" y="10" width="6" height="2" rx="1" style={{fill: "hsl(var(--background))"}}/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="hsl(var(--primary))" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="hsl(var(--primary))" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
}
if (bootState === "ready" && !hasUserMessage) {
return (
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="flex-1 flex items-center justify-center">
<div className="max-w-lg mx-auto px-4">
<div className="float-in">
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-primary to-primary flex items-center justify-center shadow-[0_0_40px_hsl(var(--primary)_/_0.3)]">
<Bot className="h-8 w-8 text-white" />
</div>
<h2 className="text-foreground font-bold text-2xl text-center mb-2">What can I help you with?</h2>
<p className="text-muted-foreground text-sm text-center leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
</div>
</div>
</div>
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="max-w-3xl mx-auto">
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
</div>
</div>
</div>
</div>
)
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex-1 flex flex-col min-h-0">
{ollamaStatus === false && ( <div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="flex items-center gap-2 px-4 py-2 bg-amber-500/10 border-b border-amber-500/20 text-amber-400 text-xs"> <div className="max-w-3xl mx-auto space-y-6">
<AlertCircle className="h-3.5 w-3.5 flex-none" /> {messages.map((msg, i) => (
<span className="flex-1">Ollama not responding. Start it with <code className="bg-amber-500/20 px-1 rounded">ollama serve</code></span> <div key={i}>
<button type="button" onClick={checkOllama} className="hover:text-amber-300"> {msg.role === "assistant" ? (
<RefreshCw className="h-3.5 w-3.5" /> <div className="flex gap-3 items-start">
</button> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 max-w-[85%]">
<div className="text-foreground text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
</div>
<div className="text-muted-foreground text-[10px] mt-1">AI Assistant</div>
</div>
</div>
) : (
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-primary to-primary rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
{msg.gif ? (
<img
src={msg.gif.url}
alt={msg.gif.title || "GIF"}
className="w-full rounded-lg max-h-64 object-cover"
loading="lazy"
/>
) : (
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
)}
</div>
</div>
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
</div>
<div className="bg-card rounded-2xl rounded-tl-sm border border-border border-l-2 border-l-primary px-5 py-4 inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-primary dot-1" />
<span className="w-2 h-2 rounded-full bg-primary dot-2" />
<span className="w-2 h-2 rounded-full bg-primary dot-3" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div> </div>
)}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{msg.role === "assistant" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
)}
<div
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === "user"
? "bg-[#1BB0CE] text-white"
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
<User className="h-4 w-4 text-white" />
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 justify-start">
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<Loader2 className="h-4 w-4 animate-spin text-[#1BB0CE]" />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div> </div>
<div className="sticky bottom-0 bg-background/95 backdrop-blur-md px-4 py-4">
<div className="border-t border-[#2a2a35] p-4"> <div className="max-w-3xl mx-auto">
{error && ( {error && (
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5"> <div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3 w-3" /> <span></span>
{error} {error}
</div>
)}
<div className="bg-card rounded-2xl border border-border focus-within:border-primary/40 focus-within:shadow-[0_0_20px_hsl(var(--primary)_/_0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3 relative" ref={containerRef}>
<button type="button" className="w-8 h-8 rounded-lg text-muted-foreground hover:text-primary hover:bg-muted/50 flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-[11px] font-semibold tracking-wide" onClick={() => setShowGifPicker((v) => !v)}>GIF</button>
{showGifPicker && <GifPicker onSelect={handleGifSelect} onClose={() => setShowGifPicker(false)} />}
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="bg-transparent flex-1 text-foreground text-sm placeholder-muted-foreground resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-primary to-primary flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_hsl(var(--primary)_/_0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-muted-foreground text-[10px]">Shift + Enter for new line</span>
<span className="text-muted-foreground text-[10px]">{input.length} / 2000</span>
</div> </div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
/>
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</button>
</div> </div>
</div> </div>
</div> </div>
) )
} })
AIChat.displayName = "AIChat"
+193
View File
@@ -0,0 +1,193 @@
"use client"
import { useState, useRef, useEffect, useCallback } from "react"
import { Search, Loader2, ImageIcon } from "lucide-react"
interface GifResult {
id: string
title: string
url: string
previewUrl: string
previewHeight: number
width: number
height: number
}
interface GifPickerProps {
onSelect: (gif: { url: string; previewUrl: string; title: string }) => void
onClose: () => void
}
const SEARCH_CACHE = new Map<string, GifResult[]>()
export function GifPicker({ onSelect, onClose }: GifPickerProps) {
const [gifs, setGifs] = useState<GifResult[]>([])
const [loading, setLoading] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(true)
const [error, setError] = useState("")
const sentinelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
const fetchGifs = useCallback(async (q: string, off: number, append: boolean) => {
if (off === 0) {
const cacheKey = q || "__trending__"
const cached = SEARCH_CACHE.get(cacheKey)
if (cached) {
setGifs(cached)
setLoading(false)
setHasMore(true)
setOffset(cached.length)
return
}
}
try {
if (!append) setLoading(true)
else setLoadingMore(true)
setError("")
const params = new URLSearchParams()
if (q) {
params.set("type", "search")
params.set("q", q)
}
params.set("offset", String(off))
params.set("limit", "20")
const res = await fetch(`/api/ai/giphy?${params}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to fetch GIFs")
}
const data = await res.json()
const newGifs: GifResult[] = data.gifs || []
if (append) {
setGifs((prev) => [...prev, ...newGifs])
} else {
setGifs(newGifs)
if (off === 0 && !q) {
SEARCH_CACHE.set("__trending__", newGifs)
}
if (q) {
SEARCH_CACHE.set(q, newGifs)
}
}
setOffset(off + newGifs.length)
setHasMore(newGifs.length === 20)
} catch (err: any) {
setError(err.message || "Failed to load GIFs")
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [])
useEffect(() => {
fetchGifs("", 0, false)
}, [fetchGifs])
useEffect(() => {
inputRef.current?.focus()
}, [])
useEffect(() => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
if (!search.trim()) {
fetchGifs("", 0, false)
return
}
searchTimeoutRef.current = setTimeout(() => {
fetchGifs(search.trim(), 0, false)
}, 400)
return () => {
if (searchTimeoutRef.current) clearTimeout(searchTimeoutRef.current)
}
}, [search, fetchGifs])
useEffect(() => {
const sentinel = sentinelRef.current
if (!sentinel) return
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore && !loading) {
fetchGifs(search.trim(), offset, true)
}
},
{ rootMargin: "200px" }
)
observer.observe(sentinel)
return () => observer.disconnect()
}, [hasMore, loadingMore, loading, offset, search, fetchGifs])
return (
<div className="absolute bottom-full left-0 right-0 mb-2 bg-card border border-border rounded-2xl shadow-xl shadow-black/20 overflow-hidden z-50">
<div className="p-3 border-b border-border">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search GIFs..."
className="w-full bg-muted/50 text-foreground text-sm rounded-xl pl-9 pr-4 py-2.5 outline-none focus:ring-2 focus:ring-primary/30 placeholder:text-muted-foreground/60"
/>
</div>
</div>
<div className="overflow-y-auto" style={{ maxHeight: "360px" }}>
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">{error}</p>
</div>
) : gifs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center">
<ImageIcon className="h-8 w-8 text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">No GIFs found.</p>
</div>
) : (
<>
<div className="grid grid-cols-2 gap-2 p-3">
{gifs.map((gif) => (
<button
key={gif.id}
type="button"
onClick={() => onSelect({ url: gif.url, previewUrl: gif.previewUrl, title: gif.title })}
className="relative rounded-xl overflow-hidden bg-muted/30 hover:ring-2 hover:ring-primary/50 transition-all duration-200 aspect-video"
title={gif.title}
>
<img
src={gif.previewUrl}
alt={gif.title || "GIF"}
loading="lazy"
className="w-full h-full object-cover"
/>
</button>
))}
</div>
{loadingMore && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
)}
{hasMore && <div ref={sentinelRef} className="h-4" />}
</>
)}
</div>
</div>
)
}
+22 -9
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { Briefcase, ChevronDown, Loader2 } from "lucide-react" import { Briefcase, ChevronDown, Loader2, Search } from "lucide-react"
interface Job { interface Job {
job_title: string job_title: string
@@ -12,9 +12,11 @@ interface Job {
interface JobSelectorProps { interface JobSelectorProps {
onSelect: (job: Job | null) => void onSelect: (job: Job | null) => void
onSearch?: (job: Job) => void
searching?: boolean
} }
export function JobSelector({ onSelect }: JobSelectorProps) { export function JobSelector({ onSelect, onSearch, searching }: JobSelectorProps) {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
@@ -39,36 +41,47 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors" className="w-full flex items-center gap-2.5 bg-card/50 border border-border hover:border-primary/20 rounded-xl px-4 py-3 text-sm text-muted-foreground hover:text-foreground transition-all duration-200"
> >
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" /> <Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate"> <span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"} {selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span> </span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />} {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> : <ChevronDown className={`h-3.5 w-3.5 text-muted-foreground/60 transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button> </button>
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto"> <div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-card border border-border rounded-xl shadow-xl max-h-60 overflow-y-auto">
{jobs.map((job, i) => ( {jobs.map((job, i) => (
<button <button
key={i} key={i}
type="button" type="button"
onClick={() => handleSelect(job)} onClick={() => handleSelect(job)}
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0" className="w-full text-left px-4 py-3 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-all duration-150 border-b border-border/20 last:border-b-0 border-l-2 border-l-transparent hover:border-l-primary/40"
> >
<div className="font-medium">{job.job_title}</div> <div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} {job.description}</div> <div className="text-xs text-muted-foreground/60 mt-0.5">{job.industry} {job.description}</div>
</button> </button>
))} ))}
{jobs.length === 0 && !loading && ( {jobs.length === 0 && !loading && (
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div> <div className="px-4 py-4 text-xs text-muted-foreground/60 text-center">No job categories loaded</div>
)} )}
</div> </div>
</> </>
)} )}
{selected && onSearch && (
<button
type="button"
onClick={() => onSearch(selected)}
disabled={searching}
className="w-full flex items-center justify-center gap-2 mt-3 bg-primary hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed text-primary-foreground text-sm font-semibold rounded-xl px-4 py-3 transition-all duration-200 shadow-lg shadow-primary/20"
>
{searching ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
{searching ? "Searching..." : "Search Facebook"}
</button>
)}
</div> </div>
) )
} }
+35 -10
View File
@@ -8,6 +8,7 @@ import { Search, Image, Sticker, X, Loader2, TrendingUp } from "lucide-react"
import data from "@emoji-mart/data" import data from "@emoji-mart/data"
import Picker from "@emoji-mart/react" import Picker from "@emoji-mart/react"
import { getStickerPacks, type StickerPack } from "@/data/stickers" import { getStickerPacks, type StickerPack } from "@/data/stickers"
import { sanitizeSvg } from "@/lib/sanitize"
type Tab = "emoji" | "gif" | "sticker" type Tab = "emoji" | "gif" | "sticker"
@@ -39,8 +40,9 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const [gifResults, setGifResults] = useState<any[]>([]) const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false) const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("") const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false) const [gifUnavailable, setGifUnavailable] = useState(false)
const [gifNext, setGifNext] = useState("") const [gifNext, setGifNext] = useState("")
const gifCache = useRef<Map<string, { results: any[]; next: string }>>(new Map())
const [recentGifs, setRecentGifs] = useState<string[]>([]) const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([]) const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks) const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
@@ -56,10 +58,23 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
return () => document.removeEventListener("mousedown", handleClick) return () => document.removeEventListener("mousedown", handleClick)
}, [onClose]) }, [onClose])
useEffect(() => { setRecentGifs(getRecent(RECENT_GIFS_KEY)) }, []) useEffect(() => {
setRecentGifs(getRecent(RECENT_GIFS_KEY))
if (!navigator.onLine) {
console.warn("[GIF Picker] Browser is offline — GIF service may be unavailable")
}
}, [])
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, []) useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => { const fetchGifs = useCallback(async (query: string, pos = "") => {
const cacheKey = `${query}::${pos}`
const cached = gifCache.current.get(cacheKey)
if (cached && !pos) {
setGifResults(cached.results)
setGifNext(cached.next)
return
}
setGifLoading(true) setGifLoading(true)
setGifError("") setGifError("")
try { try {
@@ -68,17 +83,19 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const res = await fetch(`/api/gifs?${params}`) const res = await fetch(`/api/gifs?${params}`)
const data = await res.json() const data = await res.json()
if (data.noKey) { if (data.noKey) {
setGifNoKey(true) console.error("[GIF Picker] GIPHY_API_KEY is not configured on the server. Add GIPHY_API_KEY to .env.local and restart the dev server.")
setGifUnavailable(true)
setGifResults([]) setGifResults([])
} else if (data.results) { } else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results)) setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "") setGifNext(data.next || "")
setGifNoKey(false) setGifUnavailable(false)
if (!pos) gifCache.current.set(cacheKey, { results: data.results, next: data.next || "" })
} else { } else {
setGifError("No results found") setGifError("No results found")
} }
} catch { } catch {
setGifError("Failed to load GIFs") setGifError("Could not load GIFs. Check your connection.")
} }
setGifLoading(false) setGifLoading(false)
}, []) }, [])
@@ -94,6 +111,15 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
return () => { if (searchTimer.current) clearTimeout(searchTimer.current) } return () => { if (searchTimer.current) clearTimeout(searchTimer.current) }
}, [tab, gifQuery, fetchGifs]) }, [tab, gifQuery, fetchGifs])
useEffect(() => {
if (tab !== "gif" || !gifError) return
const interval = setInterval(() => {
if (gifUnavailable) return
fetchGifs(gifQuery.trim())
}, 15000)
return () => clearInterval(interval)
}, [tab, gifError, gifUnavailable, gifQuery, fetchGifs])
const handleGifSelect = (gif: any) => { const handleGifSelect = (gif: any) => {
const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height }) const content = JSON.stringify({ gif: gif.url, w: gif.width, h: gif.height })
addRecent(RECENT_GIFS_KEY, gif.id) addRecent(RECENT_GIFS_KEY, gif.id)
@@ -196,11 +222,10 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
<div className="flex items-center justify-center h-48"> <div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : gifNoKey ? ( ) : gifUnavailable ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" /> <Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span> <span>GIF service not configured.</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
</div> </div>
) : gifError ? ( ) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
@@ -263,7 +288,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square" className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square"
> >
{sticker.svg ? ( {sticker.svg ? (
<div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sticker.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(sticker.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} />
) : ( ) : (
<span className="text-5xl">{sticker.emoji}</span> <span className="text-5xl">{sticker.emoji}</span>
)} )}
@@ -279,7 +304,7 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
if (!s) return null if (!s) return null
return ( return (
<button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100"> <button key={s.id} type="button" onClick={() => handleStickerSelect(s)} className="rounded-xl bg-muted/30 hover:bg-muted/60 transition-colors flex items-center justify-center p-2 aspect-square opacity-70 hover:opacity-100">
{s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: s.svg.replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>} {s.svg ? <div className="w-full h-full flex items-center justify-center" dangerouslySetInnerHTML={{ __html: sanitizeSvg(s.svg).replace(/<svg /, '<svg style="width:100%;height:100%" ') }} /> : <span className="text-5xl">{s.emoji}</span>}
</button> </button>
) )
})} })}
+34 -34
View File
@@ -162,11 +162,11 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center" className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
> >
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors" className="absolute top-4 right-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -174,27 +174,27 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
{callLink ? ( {callLink ? (
shareSent ? ( shareSent ? (
<> <>
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p> <p className="text-sm text-[#4A9078] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Waiting for participant</h2>
<p className="text-[#888888] text-sm mt-1 mb-4"> <p className="text-[#8A9078] text-sm mt-1 mb-4">
The recipient must receive and open the call link. The recipient must receive and open the call link.
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white"> <div className="bg-card dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-foreground dark:text-white">
{callLink} {callLink}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={copyLink} onClick={copyLink}
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex-1 flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Copy URL Copy URL
</button> </button>
<button <button
onClick={() => { setShareSent(false); setShareError(null) }} onClick={() => { setShareSent(false); setShareError(null) }}
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex-1 flex items-center justify-center gap-2 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
WhatsApp WhatsApp
@@ -205,24 +205,24 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
href={callLink} href={callLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200" className="block w-full mt-4 bg-muted-foreground hover:bg-muted-foreground/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
> >
Open Call Open Call
</a> </a>
</> </>
) : ( ) : (
<> <>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#888888] text-sm mt-1 mb-4"> <p className="text-[#8A9078] text-sm mt-1 mb-4">
Select a method to send the call link Select a method to send the call link
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white"> <div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#2D3020] dark:text-white">
{callLink} {callLink}
</div> </div>
{showWaUrl && ( {showWaUrl && (
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono"> <div className="bg-card dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#4A9078] font-mono">
{showWaUrl} {showWaUrl}
</div> </div>
)} )}
@@ -237,7 +237,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</button> </button>
<button <button
onClick={copyLink} onClick={copyLink}
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Copy URL Copy URL
@@ -245,16 +245,16 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div> </div>
{shareError && ( {shareError && (
<p className="text-[#CC0000] text-xs text-center">{shareError}</p> <p className="text-[#C84B4B] text-xs text-center">{shareError}</p>
)} )}
</> </>
) )
) : ( ) : (
<> <>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Start a Call</h2>
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p> <p className="text-[#8A9078] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2"> <p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
CONTACTS CONTACTS
</p> </p>
@@ -262,25 +262,25 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search contacts..." placeholder="Search contacts..."
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/> />
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4"> <div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
{contactsLoading && ( {contactsLoading && (
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p> <p className="text-[#8A9078] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
)} )}
{contactsError && ( {contactsError && (
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p> <p className="text-[#C84B4B] text-sm text-center py-4">Could not load contacts</p>
)} )}
{!contactsLoading && !contactsError && filteredContacts.length === 0 && ( {!contactsLoading && !contactsError && filteredContacts.length === 0 && (
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p> <p className="text-[#8A9078] text-sm text-center py-4">No contacts found</p>
)} )}
{!contactsLoading && !contactsError && filteredContacts.map((contact) => ( {!contactsLoading && !contactsError && filteredContacts.map((contact) => (
<div <div
key={contact.id} key={contact.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]" className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#FAFAF6] dark:hover:bg-[#1A1A1A]"
> >
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]"> <div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FAEAEA] dark:bg-[#CC0000]/15 text-[#C84B4B] dark:text-[#FF4444]">
{contact.avatar_url ? ( {contact.avatar_url ? (
<img <img
src={contact.avatar_url} src={contact.avatar_url}
@@ -292,13 +292,13 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p> <p className="text-sm font-medium text-[#2D3020] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p> <p className="text-xs text-[#8A9078] dark:[#666666] truncate">{contact.phone}</p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleCall(contact.phone)} onClick={() => handleCall(contact.phone)}
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity" className="shrink-0 text-[#C84B4B] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
> >
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</button> </button>
@@ -307,12 +307,12 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div> </div>
<div className="flex items-center gap-3 my-4"> <div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" /> <hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span> <span className="text-xs text-[#8A9078] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" /> <hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
</div> </div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2"> <p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
DIAL A NUMBER DIAL A NUMBER
</p> </p>
@@ -321,10 +321,10 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={phoneNumber} value={phoneNumber}
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }} onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
placeholder="+27 000 000 0000" placeholder="+27 000 000 0000"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/> />
{phoneError && ( {phoneError && (
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p> <p className="text-[#C84B4B] text-xs mt-1">Please enter a phone number</p>
)} )}
<button <button
@@ -337,7 +337,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
setPhoneError(false) setPhoneError(false)
handleCall(trimmed) handleCall(trimmed)
}} }}
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200" className="w-full mt-3 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
> >
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
Call Now Call Now
@@ -21,14 +21,14 @@ export function CrossedLightsabers() {
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" /> <circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
<g className="animate-pulse-red"> <g className="animate-pulse-red">
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-outer" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-mid" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
</g> </g>
<g className="animate-pulse-blue"> <g className="animate-pulse-blue">
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-outer" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-mid" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
</g> </g>
@@ -98,6 +98,8 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
key={i} key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)} d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`} fill={`url(#chart-grad-${i})`}
stroke="#000000"
strokeWidth="1"
opacity={hov !== null && !isH ? 0.3 : 1} opacity={hov !== null && !isH ? 0.3 : 1}
style={{ style={{
cursor: "pointer", cursor: "pointer",
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
data: IntervalData[] data: IntervalData[]
} }
const NEW_LEADS = "#CC0000" const NEW_LEADS = "#C84B4B"
const CLOSED = "#0033CC" const CLOSED = "#5A8FC4"
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) { export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear()) const [year, setYear] = useState(new Date().getFullYear())
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
> >
<defs> <defs>
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FF1111" /> <stop offset="0%" stopColor="#C84B4B" />
<stop offset="100%" stopColor={NEW_LEADS} /> <stop offset="100%" stopColor={NEW_LEADS} />
</linearGradient> </linearGradient>
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1144FF" /> <stop offset="0%" stopColor="#5A8FC4" />
<stop offset="100%" stopColor={CLOSED} /> <stop offset="100%" stopColor={CLOSED} />
</linearGradient> </linearGradient>
<filter id="shadowNew"> <filter id="shadowNew">
@@ -1,12 +1,32 @@
"use client" "use client"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export function StatCardSkeleton() { export function StatCardSkeleton() {
const { websiteTheme } = useWebsiteTheme()
if (websiteTheme === "spidey") {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#CC0000] dark:text-[#FF1111] text-5xl font-['Bangers',cursive] animate-pulse">
THWIP!
</p>
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm mt-2">
Loading your data...
</p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] animate-[loading_1.5s_ease-in-out_infinite]" />
</div>
</div>
)
}
return ( return (
<div className="col-span-full flex flex-col items-center justify-center py-20"> <div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm"> <p className="text-[#8A9078] dark:text-[#AAAAAA] text-sm">
Loading your data... Loading your data...
</p> </p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4"> <div className="w-48 h-1 rounded-full overflow-hidden bg-[#D4D8CC] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" /> <div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
</div> </div>
</div> </div>
+29 -15
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { LucideIcon } from "lucide-react" import { LucideIcon } from "lucide-react"
@@ -19,12 +20,12 @@ interface StatCardProps {
} }
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = { const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
} }
function computeGoal(max: number): number { function computeGoal(max: number): number {
@@ -50,6 +51,7 @@ function smoothPath(points: { x: number; y: number }[]): string {
} }
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) { export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
const { websiteTheme } = useWebsiteTheme()
const color = cardColors[title] ?? cardColors["Total Leads"] const color = cardColors[title] ?? cardColors["Total Leads"]
const isNumeric = typeof value === "number" const isNumeric = typeof value === "number"
@@ -108,8 +110,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const sparkColor = color.accent const sparkColor = color.accent
const isRed = index % 2 === 0 const isRed = index % 2 === 0
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]" const stripColor = isRed ? "from-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20" const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
return ( return (
<motion.div <motion.div
@@ -119,18 +121,30 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
className="relative" className="relative"
> >
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden"> <Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */} {websiteTheme === "spidey" && (
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} /> <div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
)}
<CardContent className="p-6 flex flex-col"> {websiteTheme === "spidey" && (
<div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
<CardContent className="p-6 flex flex-col relative z-[1]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white"> <p className="text-3xl font-bold tracking-tight text-[#2D3020] dark:text-white">
{isNumeric ? display : value} {isNumeric ? display : value}
</p> </p>
{description && ( {description && (
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p> <p className="text-xs text-[#8A9078] dark:text-[#666666]">{description}</p>
)} )}
{trend && ( {trend && (
<span className={cn( <span className={cn(
@@ -186,9 +200,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
{title === "Conversion Rate" && conversionRate !== undefined && ( {title === "Conversion Rate" && conversionRate !== undefined && (
<div className="mt-3"> <div className="mt-3">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden"> <div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} /> <div className="h-full rounded-full bg-gradient-to-r from-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div> </div>
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p> <p className="text-xs text-[#8A9078] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
</div> </div>
)} )}
</CardContent> </CardContent>
+46 -40
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
import { Topbar } from "./topbar" import { Topbar } from "./topbar"
@@ -11,6 +12,7 @@ interface AppShellProps {
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const { websiteTheme } = useWebsiteTheme()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname() const pathname = usePathname()
@@ -33,51 +35,55 @@ export function AppShell({ children }: AppShellProps) {
} }
return ( return (
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden" <div className="min-h-screen bg-background dark:bg-[#0A0A0A] relative overflow-hidden"
style={{ style={websiteTheme === "spidey" ? {
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)", backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px", backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px", backgroundPosition: "0 0, 7px 7px",
}} } : undefined}
> >
{/* Spider-Man top gradient bar */} {websiteTheme === "spidey" && (
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" /> <div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
)}
{/* Corner spider webs */} {websiteTheme === "spidey" && (
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]"> <>
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg"> <div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/> <svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg> <path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</div> </svg>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.07] dark:opacity-[0.12]"> </div>
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}> <div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/> <svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg> <path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</div> </svg>
</div>
</>
)}
<Sidebar <Sidebar
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
+3 -3
View File
@@ -8,17 +8,17 @@ export function CrmIcon() {
viewBox="0 0 64 64" viewBox="0 0 64 64"
aria-hidden="true" aria-hidden="true"
> >
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}> <g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" /> <path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
</g> </g>
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}> <g className="fill-[#8A9078] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" /> <path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" /> <path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" /> <path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" /> <path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" /> <path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
</g> </g>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}> <g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" /> <path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" /> <path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" /> <path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
+1 -1
View File
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
return ( return (
<svg <svg
viewBox="0 0 192.756 192.756" viewBox="0 0 192.756 192.756"
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto" className="fill-[#2D3020] dark:fill-[#eef0f7] w-full h-auto"
aria-hidden="true" aria-hidden="true"
> >
<g fill-rule="evenodd" clip-rule="evenodd"> <g fill-rule="evenodd" clip-rule="evenodd">
+5 -5
View File
@@ -62,7 +62,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
> >
{/* Logo */} {/* Logo */}
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}> <div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
<Link href="/" className="flex items-center gap-3 overflow-hidden"> <Link href="/dashboard" className="flex items-center gap-3 overflow-hidden">
{collapsed ? ( {collapsed ? (
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div> <div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
) : ( ) : (
@@ -95,7 +95,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
"relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors", "relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors",
isActive isActive
? "bg-sidebar-primary text-sidebar-primary-foreground" ? "bg-sidebar-primary text-sidebar-primary-foreground"
: "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" : "text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-white"
)} )}
> >
<item.icon className="h-5 w-5" /> <item.icon className="h-5 w-5" />
@@ -104,7 +104,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
)} )}
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="ml-2"> <TooltipContent side="right" className="ml-2 text-white">
{item.label} {item.label}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@@ -143,12 +143,12 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
onClick={() => setFbDialogOpen(true)} onClick={() => setFbDialogOpen(true)}
className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground" className="relative flex h-10 w-10 items-center justify-center rounded-lg transition-colors text-sidebar-foreground/60 hover:bg-sidebar-accent hover:text-white"
> >
<Facebook className="h-5 w-5" /> <Facebook className="h-5 w-5" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="ml-2"> <TooltipContent side="right" className="ml-2 text-white">
Facebook Accounts Facebook Accounts
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
+1 -1
View File
@@ -36,7 +36,7 @@ export function SystemMonitor({ collapsed }: SystemMonitorProps) {
if (collapsed) { if (collapsed) {
return ( return (
<div className="border-t border-sidebar-border px-3 py-2 flex justify-center gap-1.5"> <div className="border-t border-sidebar-border px-1 py-1.5 flex flex-col items-center text-[10px] leading-tight">
<span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}> <span className={ramOver ? "text-red-400" : "text-[var(--theme-primary,#3b91f7)]"}>
{rssMB} {rssMB}
</span> </span>
+16 -11
View File
@@ -32,9 +32,8 @@ import {
CheckCheck, CheckCheck,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { CrmIcon } from "./crm-icon";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
interface TopbarProps { interface TopbarProps {
onMenuClick: () => void; onMenuClick: () => void;
@@ -74,11 +73,11 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase(); .toUpperCase();
return ( return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden"> <header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0"> <div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" ? <CaptainAmericaShield /> : <CrmIcon />} {websiteTheme === "spidey" && <CaptainAmericaShield />}
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide"> <span className="text-primary font-bold text-sm tracking-wide">
CRM CRM
</span> </span>
</div> </div>
@@ -95,10 +94,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* Search */} {/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80"> <div className="relative hidden flex-1 sm:block md:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input
placeholder="Search leads, companies..." placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white" className="h-9 w-full max-w-sm pl-9 bg-muted border-border text-foreground"
/> />
</div> </div>
@@ -118,7 +117,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-muted-foreground" className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
> >
{mounted ? ( {mounted ? (
theme === "dark" ? ( theme === "dark" ? (
@@ -136,7 +135,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setBugModalOpen(true)} onClick={() => setBugModalOpen(true)}
className="text-muted-foreground" className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
title="Report a Bug" title="Report a Bug"
> >
<Bug className="h-5 w-5" /> <Bug className="h-5 w-5" />
@@ -148,7 +147,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative text-muted-foreground" className="relative text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground"> <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
@@ -218,7 +217,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* User profile */} {/* User profile */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3"> <Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3 hover:bg-[#8b949e]/10 hover:text-[#8b949e]">
<Avatar className="h-7 w-7"> <Avatar className="h-7 w-7">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
@@ -258,6 +257,12 @@ export function Topbar({ onMenuClick }: TopbarProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
{websiteTheme === "spidey" && (
<div className="absolute right-0 top-0 bottom-0 w-[6px] pointer-events-none opacity-80 rounded-r-[16px] overflow-hidden">
<div className="h-1/2 bg-[#CC0000] dark:bg-[#FF1111]" />
<div className="h-1/2 bg-[#0033CC] dark:bg-[#1144FF]" />
</div>
)}
<BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} /> <BugReportModal open={bugModalOpen} onClose={() => setBugModalOpen(false)} />
</header> </header>
); );
+8 -32
View File
@@ -6,11 +6,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Sparkles, Shield } from "lucide-react" import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider" import { useWebsiteTheme } from "@/providers/website-theme-provider"
const COLOR_THEME_KEY = "crm-color-theme"
const modeOptions = [ const modeOptions = [
{ value: "light", icon: Sun, label: "Light" }, { value: "light", icon: Sun, label: "Light" },
{ value: "dark", icon: Moon, label: "Dark" }, { value: "dark", icon: Moon, label: "Dark" },
@@ -31,42 +29,19 @@ const themeOptions = [
] ]
const backgroundOptions = [ const backgroundOptions = [
{ value: "default", label: "Default", icon: Shield, color: "bg-zinc-500", ring: "ring-zinc-500", desc: "Standard CRM appearance" },
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" }, { value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
{ value: "starforce", label: "StarForce", icon: Sparkles, color: "bg-red-700", ring: "ring-red-700", desc: "Cosmic dark with starfield & green glow" },
] ]
function getStoredColorTheme(): string {
if (typeof window === "undefined") return "default"
return localStorage.getItem(COLOR_THEME_KEY) || "default"
}
function applyColorTheme(theme: string) {
document.documentElement.className = document.documentElement.className
.replace(/\b(default|ocean|forest|sunset|midnight|rose|amber|violet|slate|ruby)\b/g, "")
.trim()
if (theme !== "default") {
document.documentElement.classList.add(theme)
}
localStorage.setItem(COLOR_THEME_KEY, theme)
}
export function ThemeSettings() { export function ThemeSettings() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
const { websiteTheme, setWebsiteTheme } = useWebsiteTheme() const { websiteTheme, setWebsiteTheme, colorTheme, setColorTheme } = useWebsiteTheme()
const [colorTheme, setColorTheme] = useState("default")
const [mounted, setMounted] = useState(false) const [mounted, setMounted] = useState(false)
useEffect(() => { useEffect(() => {
setMounted(true) setMounted(true)
setColorTheme(getStoredColorTheme())
applyColorTheme(getStoredColorTheme())
}, []) }, [])
function handleColorChange(value: string) {
setColorTheme(value)
applyColorTheme(value)
}
if (!mounted) return null if (!mounted) return null
return ( return (
@@ -86,7 +61,8 @@ export function ThemeSettings() {
<Label <Label
htmlFor={`mode-${value}`} htmlFor={`mode-${value}`}
className={cn( className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all", "flex flex-col items-center gap-3 rounded-lg border-2 p-4 cursor-pointer transition-all",
value === "dark" ? "hover:bg-muted" : "hover:bg-accent",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5" "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)} )}
> >
@@ -107,14 +83,14 @@ export function ThemeSettings() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<RadioGroup value={colorTheme} onValueChange={handleColorChange} className="grid grid-cols-5 gap-4"> <RadioGroup value={colorTheme} onValueChange={setColorTheme} className="grid grid-cols-5 gap-4">
{themeOptions.map(({ value, label, color, ring }) => ( {themeOptions.map(({ value, label, color, ring }) => (
<div key={value}> <div key={value}>
<RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" /> <RadioGroupItem value={value} id={`color-${value}`} className="peer sr-only" />
<Label <Label
htmlFor={`color-${value}`} htmlFor={`color-${value}`}
className={cn( className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all", "flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5" "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)} )}
> >
@@ -142,7 +118,7 @@ export function ThemeSettings() {
<Label <Label
htmlFor={`bg-${value}`} htmlFor={`bg-${value}`}
className={cn( className={cn(
"flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-accent cursor-pointer transition-all", "flex flex-col items-center gap-3 rounded-lg border-2 p-4 hover:bg-muted cursor-pointer transition-all",
"peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5" "peer-data-[state=checked]:border-primary peer-data-[state=checked]:bg-primary/5"
)} )}
> >
+19 -19
View File
@@ -75,7 +75,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
> >
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors" className="absolute right-4 top-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -83,15 +83,15 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
{submitted ? ( {submitted ? (
<div className="flex flex-col items-center py-8 text-center"> <div className="flex flex-col items-center py-8 text-center">
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" /> <CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2"> <h3 className="text-lg font-semibold text-[#2D3020] dark:text-white mb-2">
Bug Report Submitted Bug Report Submitted
</h3> </h3>
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]"> <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA]">
Thank you for your report. The development team will investigate. Thank you for your report. The development team will investigate.
</p> </p>
<button <button
onClick={handleClose} onClick={handleClose}
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors" className="mt-6 rounded-lg bg-[#C84B4B] px-6 py-2 text-sm font-medium text-white hover:bg-[#C84B4B]/80 transition-colors"
> >
Done Done
</button> </button>
@@ -99,14 +99,14 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
) : ( ) : (
<> <>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#FAEAEA] dark:bg-red-900/30">
<Bug className="h-5 w-5 text-[#CC0000]" /> <Bug className="h-5 w-5 text-[#C84B4B]" />
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-[#111111] dark:text-white"> <h3 className="text-lg font-semibold text-[#2D3020] dark:text-white">
Report a Bug Report a Bug
</h3> </h3>
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]"> <p className="text-xs text-[#8A9078] dark:text-[#AAAAAA]">
Help us improve the system Help us improve the system
</p> </p>
</div> </div>
@@ -120,8 +120,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Title <span className="text-[#CC0000]">*</span> Title <span className="text-[#C84B4B]">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -129,13 +129,13 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Brief description of the issue" placeholder="Brief description of the issue"
required required
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Description <span className="text-[#CC0000]">*</span> Description <span className="text-[#C84B4B]">*</span>
</label> </label>
<textarea <textarea
value={description} value={description}
@@ -143,18 +143,18 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
placeholder="What happened? What did you expect?" placeholder="What happened? What did you expect?"
rows={4} rows={4}
required required
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full resize-none rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Severity Severity
</label> </label>
<select <select
value={severity} value={severity}
onChange={(e) => setSeverity(e.target.value)} onChange={(e) => setSeverity(e.target.value)}
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
> >
<option value="low">Low Minor cosmetic issue</option> <option value="low">Low Minor cosmetic issue</option>
<option value="medium">Medium Affects functionality</option> <option value="medium">Medium Affects functionality</option>
@@ -163,8 +163,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
</select> </select>
</div> </div>
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2"> <div className="rounded-lg bg-[#FAFAF6] dark:bg-[#1A1A1A] px-3 py-2">
<p className="text-xs text-[#888888]"> <p className="text-xs text-[#8A9078]">
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""} <span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
</p> </p>
</div> </div>
@@ -172,7 +172,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors" className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#C84B4B] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#C84B4B]/80 disabled:opacity-50 transition-colors"
> >
{loading && <Loader2 className="h-4 w-4 animate-spin" />} {loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Submitting..." : "Submit Bug Report"} {loading ? "Submitting..." : "Submit Bug Report"}
@@ -8,24 +8,11 @@ export function CaptainAmericaShield() {
viewBox="0 0 100 100" viewBox="0 0 100 100"
aria-hidden="true" aria-hidden="true"
> >
<defs> <circle cx="50" cy="50" r="48" fill="#c62828" />
<clipPath id="shield-clip"> <circle cx="50" cy="50" r="36" fill="#efefef" />
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" /> <circle cx="50" cy="50" r="24" fill="#c62828" />
</clipPath> <circle cx="50" cy="50" r="14" fill="#1565c0" />
</defs> <polygon points="50,34 55,48 70,48 58,57 62,72 50,62 38,72 42,57 30,48 45,48" fill="#efefef" />
<g clipPath="url(#shield-clip)">
<circle cx="50" cy="45" r="45" fill="#c62828" />
<circle cx="50" cy="45" r="35" fill="#efefef" />
<circle cx="50" cy="45" r="25" fill="#c62828" />
<circle cx="50" cy="45" r="15" fill="#1565c0" />
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
</g>
<polygon
points="50,95 5,60 5,30 50,5 95,30 95,60"
fill="none"
stroke="#c62828"
strokeWidth="1.5"
/>
</svg> </svg>
) )
} }
+2 -2
View File
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
className={cn("flex items-center justify-between pb-6", className)} className={cn("flex items-center justify-between pb-6", className)}
> >
<div> <div>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1> <h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-primary pl-4 text-[#2D3020] dark:text-white">{title}</h1>
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>} {description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
</div> </div>
{children && <div className="flex items-center gap-3">{children}</div>} {children && <div className="flex items-center gap-3">{children}</div>}
</motion.div> </motion.div>
+4 -4
View File
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
}) })
return [ return [
{ name: "Open", value: statusCounts.open, color: "#CC0000" }, { name: "Open", value: statusCounts.open, color: "#C84B4B" },
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" }, { name: "Contacted", value: statusCounts.contacted, color: "#5A8FC4" },
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" }, { name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" }, { name: "Closed", value: statusCounts.closed, color: "#5A8FC4" },
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" }, { name: "Ignored", value: statusCounts.ignored, color: "#C84B4B" },
] ]
} }
+61 -26
View File
@@ -1,41 +1,76 @@
import http from "http"
const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001" const AI_SERVICE = process.env.AI_SERVICE_URL || "http://localhost:3001"
function parseUrl(url: string) {
const u = new URL(url)
return { hostname: u.hostname, port: parseInt(u.port) || 80, path: u.pathname + u.search }
}
export async function chatWithAI(message: string, jwtToken: string) { export async function chatWithAI(message: string, jwtToken: string) {
const res = await fetch(`${AI_SERVICE}/ai/chat`, { const body = JSON.stringify({ message })
method: "POST", const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/chat`)
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}` },
body: JSON.stringify({ message }), const text = await new Promise<string>((resolve, reject) => {
const req = http.request(
{ hostname, port, path, method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${jwtToken}`, "Content-Length": Buffer.byteLength(body) } },
(res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
resolve(data)
} else {
reject(new Error(`AI error ${res.statusCode}: ${data.substring(0, 200)}`))
}
})
}
)
req.on("error", reject)
req.write(body)
req.end()
}) })
if (!res.ok) { const data = JSON.parse(text)
const text = await res.text()
throw new Error(`AI service error (${res.status}): ${text}`)
}
const data = await res.json()
return data.response || "" return data.response || ""
} }
export async function fetchJobs() { export async function checkAiServiceStatus() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/health`)
try { try {
const res = await fetch(`${AI_SERVICE}/ai/jobs`) await new Promise<void>((resolve, reject) => {
if (!res.ok) return [] const req = http.get({ hostname, port, path, timeout: 3000 }, (res) => {
const data = await res.json() let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => {
try { resolve(JSON.parse(data).status === "ok" ? undefined : reject(new Error("not ok"))) } catch { reject(new Error("bad response")) }
})
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
return true
} catch {
return false
}
}
export async function fetchJobs() {
const { hostname, port, path } = parseUrl(`${AI_SERVICE}/ai/jobs`)
try {
const text = await new Promise<string>((resolve, reject) => {
const req = http.get({ hostname, port, path, timeout: 5000 }, (res) => {
let data = ""
res.on("data", (chunk) => data += chunk)
res.on("end", () => resolve(data))
})
req.on("error", reject)
req.on("timeout", () => { req.destroy(); reject(new Error("timeout")) })
})
const data = JSON.parse(text)
return data.jobs || [] return data.jobs || []
} catch { } catch {
console.warn("Failed to fetch AI jobs") console.warn("Failed to fetch AI jobs")
return [] return []
} }
} }
export async function checkAiServiceStatus() {
try {
const res = await fetch(`${AI_SERVICE}/health`)
if (!res.ok) return false
const data = await res.json()
return data.status === "ok"
} catch {
console.warn("Failed to check AI service status")
return false
}
}
+4 -48
View File
@@ -1,15 +1,9 @@
import { SignJWT, jwtVerify } from "jose";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { signToken, verifyToken } from "@/lib/jwt";
const RAW_SECRET = process.env.JWT_SECRET; export const SESSION_COOKIE = "session";
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15; const LOCKOUT_DURATION_MINUTES = 15;
@@ -35,23 +29,6 @@ export async function comparePassword(
return bcrypt.compare(password, hash); return bcrypt.compare(password, hash);
} }
export async function signToken(payload: { userId: string; role: string }) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("24h")
.setIssuedAt()
.sign(JWT_SECRET);
}
export async function verifyToken(token: string) {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return payload as { userId: string; role: string };
} catch {
return null;
}
}
export async function getUserByEmail(email: string) { export async function getUserByEmail(email: string) {
const result = await query( const result = await query(
` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name, ` SELECT u.id, u.username, u.email, u.password_hash, u.first_name, u.last_name,
@@ -195,28 +172,7 @@ export function mapDbUserToSessionUser(
} }
export async function createSession(userId: string, role: string) { export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role }); return signToken({ userId, role });
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return token;
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 0,
});
} }
export async function encryptPassword(password: string): Promise<string> { export async function encryptPassword(password: string): Promise<string> {
@@ -234,7 +190,7 @@ export async function decryptPassword(encrypted: string): Promise<string | null>
} }
export async function setSessionContext(userId: string, ip?: string) { export async function setSessionContext(userId: string, ip?: string) {
await query("SELECT set_config('app.current_user_id', $1, true)", [userId]); await query("SELECT set_session_user_context($1)", [userId]);
if (ip) { if (ip) {
await query("SELECT set_config('app.current_ip', $1, true)", [ip]); await query("SELECT set_config('app.current_ip', $1, true)", [ip]);
} }

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