Merge remote-tracking branch 'origin/main' into master
Build & Auto-Repair / build (push) Has been cancelled
Build & Auto-Repair / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build & Auto-Repair
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, develop]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20"
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Install Playwright
|
||||||
|
run: npx playwright install chromium firefox
|
||||||
|
|
||||||
|
- name: Build check
|
||||||
|
id: build
|
||||||
|
continue-on-error: true
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Run self-healing setup
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
run: node scripts/setup.mjs --self-heal
|
||||||
|
|
||||||
|
- name: Run code repair agent
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
env:
|
||||||
|
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
|
||||||
|
run: node scripts/code-repair-agent.mjs --ci
|
||||||
|
|
||||||
|
- name: Re-check build after repair
|
||||||
|
id: rebuild
|
||||||
|
continue-on-error: true
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- name: Commit fixes
|
||||||
|
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
|
||||||
|
run: |
|
||||||
|
git config user.name "CRM Repair Bot"
|
||||||
|
git config user.email "bot@coastit.co.za"
|
||||||
|
git add -A
|
||||||
|
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
|
||||||
|
git push
|
||||||
|
|
||||||
|
- name: Build failed — report
|
||||||
|
if: steps.rebuild.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
echo "::error::Build still failing after auto-repair. Manual intervention required."
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
|
||||||
|
"templates": {
|
||||||
|
"data/stickers": {
|
||||||
|
"template": "stickers.ts",
|
||||||
|
"description": "Emoji-based sticker packs for media picker"
|
||||||
|
},
|
||||||
|
"data/constants": {
|
||||||
|
"template": "constants.ts",
|
||||||
|
"description": "App-wide constants (lead statuses, roles, event types)"
|
||||||
|
},
|
||||||
|
"lib/utils": {
|
||||||
|
"template": "utils.ts",
|
||||||
|
"description": "Utility functions (cn, formatters, helpers)"
|
||||||
|
},
|
||||||
|
"types/index": {
|
||||||
|
"template": "types-index.ts",
|
||||||
|
"description": "Core type definitions (User, Lead, DashboardStats)"
|
||||||
|
},
|
||||||
|
"hooks/use-media": {
|
||||||
|
"template": "hooks/use-media.ts",
|
||||||
|
"description": "Media query hook"
|
||||||
|
},
|
||||||
|
"hooks/use-debounce": {
|
||||||
|
"template": "hooks/use-debounce.ts",
|
||||||
|
"description": "Debounce hook"
|
||||||
|
},
|
||||||
|
"hooks/use-local-storage": {
|
||||||
|
"template": "hooks/use-local-storage.ts",
|
||||||
|
"description": "LocalStorage hook with SSR safety"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fallbackTemplates": {
|
||||||
|
"@/data/*": "data-generic.ts",
|
||||||
|
"@/lib/*": "lib-generic.ts",
|
||||||
|
"@/hooks/*": "hook-generic.ts",
|
||||||
|
"@/types/*": "types-generic.ts",
|
||||||
|
"@/components/*": "component-generic.tsx",
|
||||||
|
"@/utils/*": "utils-generic.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Component Template ────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/components/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export default function GenericComponent() {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// ── App Constants ────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
|
||||||
|
|
||||||
|
export const APP_NAME = "CoastIT CRM";
|
||||||
|
export const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export const LEAD_STATUSES = {
|
||||||
|
new: { label: "New", color: "bg-blue-500" },
|
||||||
|
contacted: { label: "Contacted", color: "bg-yellow-500" },
|
||||||
|
qualified: { label: "Qualified", color: "bg-purple-500" },
|
||||||
|
proposal: { label: "Proposal", color: "bg-orange-500" },
|
||||||
|
won: { label: "Won", color: "bg-green-500" },
|
||||||
|
lost: { label: "Lost", color: "bg-red-500" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
super_admin: { label: "Super Admin", level: 1 },
|
||||||
|
admin: { label: "Admin", level: 2 },
|
||||||
|
sales: { label: "Sales", level: 3 },
|
||||||
|
dev: { label: "Developer", level: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"meeting",
|
||||||
|
"call",
|
||||||
|
"email",
|
||||||
|
"task",
|
||||||
|
"note",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const EVENT_STATUSES = [
|
||||||
|
"scheduled",
|
||||||
|
"completed",
|
||||||
|
"cancelled",
|
||||||
|
"rescheduled",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const AI_MODELS = {
|
||||||
|
chat: "llama3.2:3b",
|
||||||
|
scraper: "dolphin-llama3:8b",
|
||||||
|
coder: "qwen2.5-coder:1.5b-base",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const PAGINATION = {
|
||||||
|
defaultLimit: 50,
|
||||||
|
maxLimit: 200,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const DATE_FORMATS = {
|
||||||
|
short: "MMM d, yyyy",
|
||||||
|
long: "MMMM d, yyyy",
|
||||||
|
time: "h:mm a",
|
||||||
|
datetime: "MMM d, yyyy h:mm a",
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// ── Generic Data Module Template ─────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/data/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export interface DataItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataItems(): DataItem[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDataItem(id: string): DataItem | undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// ── Generic Hook Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/hooks/* imports. Customize as needed.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
|
||||||
|
const [value, setValue] = useState(initialValue);
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// ── useDebounce Hook ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debouncedValue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// ── useLocalStorage Hook ──────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
|
||||||
|
const [storedValue, setStoredValue] = useState<T>(initialValue);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const item = window.localStorage.getItem(key);
|
||||||
|
if (item) setStoredValue(JSON.parse(item));
|
||||||
|
} catch {
|
||||||
|
// corrupted data — use initial value
|
||||||
|
}
|
||||||
|
}, [key]);
|
||||||
|
|
||||||
|
const setValue = (value: T | ((prev: T) => T)) => {
|
||||||
|
try {
|
||||||
|
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||||
|
setStoredValue(valueToStore);
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||||
|
} catch {
|
||||||
|
// storage full or disabled
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return [storedValue, setValue];
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ── useMedia Hook ─────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit to customize.
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
export function useMedia(query: string): boolean {
|
||||||
|
const [matches, setMatches] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
setMatches(mql.matches);
|
||||||
|
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
|
||||||
|
mql.addEventListener("change", handler);
|
||||||
|
return () => mql.removeEventListener("change", handler);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Lib Module Template ──────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/lib/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export function libFunction(): void {
|
||||||
|
// Placeholder
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// ── Sticker Packs ─────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||||
|
|
||||||
|
export interface Sticker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
svg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stickers: Sticker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionStickers: Sticker[] = [
|
||||||
|
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
|
||||||
|
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
|
||||||
|
{ id: "heart", name: "Heart", emoji: "❤️" },
|
||||||
|
{ id: "laugh", name: "Laugh", emoji: "😂" },
|
||||||
|
{ id: "wow", name: "Wow", emoji: "😮" },
|
||||||
|
{ id: "sad", name: "Sad", emoji: "😢" },
|
||||||
|
{ id: "angry", name: "Angry", emoji: "😡" },
|
||||||
|
{ id: "clap", name: "Clap", emoji: "👏" },
|
||||||
|
{ id: "fire", name: "Fire", emoji: "🔥" },
|
||||||
|
{ id: "100", name: "100", emoji: "💯" },
|
||||||
|
{ id: "eyes", name: "Eyes", emoji: "👀" },
|
||||||
|
{ id: "pray", name: "Pray", emoji: "🙏" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const animalStickers: Sticker[] = [
|
||||||
|
{ id: "cat", name: "Cat", emoji: "🐱" },
|
||||||
|
{ id: "dog", name: "Dog", emoji: "🐶" },
|
||||||
|
{ id: "panda", name: "Panda", emoji: "🐼" },
|
||||||
|
{ id: "fox", name: "Fox", emoji: "🦊" },
|
||||||
|
{ id: "bear", name: "Bear", emoji: "🐻" },
|
||||||
|
{ id: "koala", name: "Koala", emoji: "🐨" },
|
||||||
|
{ id: "tiger", name: "Tiger", emoji: "🐯" },
|
||||||
|
{ id: "lion", name: "Lion", emoji: "🦁" },
|
||||||
|
{ id: "monkey", name: "Monkey", emoji: "🐵" },
|
||||||
|
{ id: "frog", name: "Frog", emoji: "🐸" },
|
||||||
|
{ id: "penguin", name: "Penguin", emoji: "🐧" },
|
||||||
|
{ id: "bird", name: "Bird", emoji: "🐦" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const foodStickers: Sticker[] = [
|
||||||
|
{ id: "pizza", name: "Pizza", emoji: "🍕" },
|
||||||
|
{ id: "burger", name: "Burger", emoji: "🍔" },
|
||||||
|
{ id: "taco", name: "Taco", emoji: "🌮" },
|
||||||
|
{ id: "sushi", name: "Sushi", emoji: "🍣" },
|
||||||
|
{ id: "ramen", name: "Ramen", emoji: "🍜" },
|
||||||
|
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
|
||||||
|
{ id: "cake", name: "Cake", emoji: "🍰" },
|
||||||
|
{ id: "coffee", name: "Coffee", emoji: "☕" },
|
||||||
|
{ id: "beer", name: "Beer", emoji: "🍺" },
|
||||||
|
{ id: "wine", name: "Wine", emoji: "🍷" },
|
||||||
|
{ id: "donut", name: "Donut", emoji: "🍩" },
|
||||||
|
{ id: "cookie", name: "Cookie", emoji: "🍪" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activityStickers: Sticker[] = [
|
||||||
|
{ id: "football", name: "Football", emoji: "⚽" },
|
||||||
|
{ id: "basketball", name: "Basketball", emoji: "🏀" },
|
||||||
|
{ id: "tennis", name: "Tennis", emoji: "🎾" },
|
||||||
|
{ id: "running", name: "Running", emoji: "🏃" },
|
||||||
|
{ id: "swimming", name: "Swimming", emoji: "🏊" },
|
||||||
|
{ id: "cycling", name: "Cycling", emoji: "🚴" },
|
||||||
|
{ id: "gym", name: "Gym", emoji: "🏋️" },
|
||||||
|
{ id: "yoga", name: "Yoga", emoji: "🧘" },
|
||||||
|
{ id: "music", name: "Music", emoji: "🎵" },
|
||||||
|
{ id: "party", name: "Party", emoji: "🎉" },
|
||||||
|
{ id: "gift", name: "Gift", emoji: "🎁" },
|
||||||
|
{ id: "trophy", name: "Trophy", emoji: "🏆" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const stickerPacks: StickerPack[] = [
|
||||||
|
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
|
||||||
|
{ id: "animals", name: "Animals", stickers: animalStickers },
|
||||||
|
{ id: "food", name: "Food & Drink", stickers: foodStickers },
|
||||||
|
{ id: "activities", name: "Activities", stickers: activityStickers },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return stickerPacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerPack(id: string): StickerPack | undefined {
|
||||||
|
return stickerPacks.find((p) => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
|
||||||
|
const pack = getStickerPack(packId);
|
||||||
|
return pack?.stickers.find((s) => s.id === stickerId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// ── Generic Types Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/types/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export type GenericType = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface GenericInterface {
|
||||||
|
id: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// ── Type Definitions ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
|
||||||
|
|
||||||
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
role: UserRole;
|
||||||
|
active: boolean;
|
||||||
|
avatar: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
contactName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
assignedUserId: string | null;
|
||||||
|
assignedUser: User | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadStatus =
|
||||||
|
| "new"
|
||||||
|
| "contacted"
|
||||||
|
| "qualified"
|
||||||
|
| "proposal"
|
||||||
|
| "won"
|
||||||
|
| "lost";
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalLeads: number;
|
||||||
|
newLeads: number;
|
||||||
|
contactedLeads: number;
|
||||||
|
qualifiedLeads: number;
|
||||||
|
proposalLeads: number;
|
||||||
|
wonLeads: number;
|
||||||
|
lostLeads: number;
|
||||||
|
conversionRate: number;
|
||||||
|
monthlyBreakdown: {
|
||||||
|
label: string;
|
||||||
|
total: number;
|
||||||
|
new: number;
|
||||||
|
contacted: number;
|
||||||
|
qualified: number;
|
||||||
|
proposal: number;
|
||||||
|
won: number;
|
||||||
|
lost: number;
|
||||||
|
}[];
|
||||||
|
leadsPerMonth: { label: string; leads: number; won: number }[];
|
||||||
|
recentLeads: Lead[];
|
||||||
|
statusDistribution: { name: string; value: number; color: string }[];
|
||||||
|
periodLabel: string;
|
||||||
|
trends: Record<string, { pct: number; up: boolean }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Note {
|
||||||
|
id: string;
|
||||||
|
leadId: string;
|
||||||
|
userId: string;
|
||||||
|
authorName: string;
|
||||||
|
authorAvatar: string;
|
||||||
|
authorRole: UserRole;
|
||||||
|
note: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NotificationType =
|
||||||
|
| "lead_created"
|
||||||
|
| "lead_status_changed"
|
||||||
|
| "lead_assigned"
|
||||||
|
| "chat_message"
|
||||||
|
| "note_added"
|
||||||
|
| "event_scheduled";
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
timestamp: string;
|
||||||
|
read: boolean;
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
senderId: string;
|
||||||
|
senderName: string;
|
||||||
|
senderAvatar: string;
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
participants: { id: string; name: string; avatar: string; role: string }[];
|
||||||
|
lastMessage: string;
|
||||||
|
lastMessageTime: string;
|
||||||
|
unread: number;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventType = "meeting" | "call" | "email" | "task" | "note";
|
||||||
|
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
|
||||||
|
|
||||||
|
export interface CalendarEvent {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
participantId: string | null;
|
||||||
|
developerId: string | null;
|
||||||
|
leadId: string | null;
|
||||||
|
conversationId: string | null;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
participantNotes: string | null;
|
||||||
|
eventType: EventType;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string | null;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
status: EventStatus;
|
||||||
|
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
|
||||||
|
lead: { id: string; companyName: string; contactName: string } | null;
|
||||||
|
clientName: string | null;
|
||||||
|
clientEmail: string | null;
|
||||||
|
clientPhone: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
// ── Generic Utils Template ────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
// Placeholder for @/utils/* imports. Customize as needed.
|
||||||
|
|
||||||
|
export function utilsFunction(): void {
|
||||||
|
// Placeholder
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// ── Utility Functions ────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
|
||||||
|
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throttle<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
limit: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let inThrottle = false;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
if (!inThrottle) {
|
||||||
|
fn(...args);
|
||||||
|
inThrottle = true;
|
||||||
|
setTimeout(() => (inThrottle = false), limit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
|
||||||
|
return classes.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
|
||||||
|
return fn().catch((err) =>
|
||||||
|
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Default Theme — Clean, Professional SaaS Design Tokens
|
||||||
|
This is the standard CRM appearance. No Spidey branding or assets.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.theme-default {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 210 80% 52%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 215 25% 22%;
|
||||||
|
--input: 215 25% 22%;
|
||||||
|
--ring: 210 80% 52%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 222.2 84% 4.9%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 210 80% 52%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 215 25% 22%;
|
||||||
|
--sidebar-ring: 210 80% 52%;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+7
-1
@@ -6,13 +6,19 @@
|
|||||||
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
|
||||||
"dev:signaling": "node signaling-server.mjs",
|
"dev:signaling": "node signaling-server.mjs",
|
||||||
"dev:open": "node scripts/open-browser.mjs",
|
"dev:open": "node scripts/open-browser.mjs",
|
||||||
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
|
||||||
|
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
|
||||||
"dev:next": "next dev -p 3006",
|
"dev:next": "next dev -p 3006",
|
||||||
"dev:precheck": "node scripts/precheck.mjs",
|
"dev:precheck": "node scripts/precheck.mjs",
|
||||||
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
"dev:ollama": "node scripts/ensure-ollama.mjs",
|
||||||
"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",
|
||||||
|
"setup:self-heal": "node scripts/setup.mjs --self-heal",
|
||||||
|
"repair": "node scripts/code-repair-agent.mjs",
|
||||||
|
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
|
||||||
|
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
|
||||||
|
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start -p 3006",
|
"start": "next start -p 3006",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
|
|||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// ── Code Repair Agent ─────────────────────────────────────────────
|
||||||
|
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
|
||||||
|
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
|
||||||
|
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
|
||||||
|
//
|
||||||
|
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
|
||||||
|
// Model: qwen2.5-coder:1.5b-base
|
||||||
|
//
|
||||||
|
// ── Architecture ──────────────────────────────────────────────────
|
||||||
|
// 1. Capture build output from `npx tsc --noEmit`
|
||||||
|
// 2. Parse error locations (file, line, column, message)
|
||||||
|
// 3. Read the failing source file + surrounding context (~20 lines)
|
||||||
|
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
|
||||||
|
// 5. Apply fix if confidence > threshold (0.7)
|
||||||
|
// 6. Re-run build to verify fix
|
||||||
|
// 7. If breaking change detected (wide-reaching import changes), escalate
|
||||||
|
// 8. In --ci mode: auto-commit with [bot] prefix
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
|
||||||
|
import { resolve, dirname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
|
||||||
|
const SELF = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const _require = createRequire(import.meta.url)
|
||||||
|
const ROOT = resolve(SELF, "..")
|
||||||
|
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
|
||||||
|
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
|
||||||
|
const MAX_ITERATIONS = 5
|
||||||
|
const CONFIDENCE_THRESHOLD = 0.7
|
||||||
|
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
|
||||||
|
|
||||||
|
// ── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function log(msg, level = "info") {
|
||||||
|
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
|
||||||
|
console.log(` ${prefix} [repair] ${msg}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function runTsc() {
|
||||||
|
try {
|
||||||
|
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
|
||||||
|
return { ok: true, output: out.toString() }
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, output: e.stdout?.toString() || e.message || "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseErrors(output) {
|
||||||
|
const errors = []
|
||||||
|
// Match: src/file.ts(line,col): error TSxxxx: message
|
||||||
|
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
|
||||||
|
let m
|
||||||
|
while ((m = lineRegex.exec(output)) !== null) {
|
||||||
|
errors.push({
|
||||||
|
file: resolve(ROOT, m[1]),
|
||||||
|
line: parseInt(m[2], 10),
|
||||||
|
column: parseInt(m[3], 10),
|
||||||
|
message: m[4].trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
function readContext(filePath, errorLine, contextLines = 20) {
|
||||||
|
try {
|
||||||
|
const lines = readFileSync(filePath, "utf-8").split("\n")
|
||||||
|
const start = Math.max(0, errorLine - 1 - contextLines)
|
||||||
|
const end = Math.min(lines.length, errorLine - 1 + contextLines)
|
||||||
|
return {
|
||||||
|
content: lines.slice(start, end).join("\n"),
|
||||||
|
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
|
||||||
|
totalLines: lines.length,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ollama Integration ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function queryOllama(prompt) {
|
||||||
|
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
prompt,
|
||||||
|
stream: false,
|
||||||
|
options: { temperature: 0.3, num_predict: 2048 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
|
||||||
|
const data = await response.json()
|
||||||
|
return data.response || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRepairPrompt(filePath, errorMessage, codeContext) {
|
||||||
|
return `You are a TypeScript repair agent. Fix the following error.
|
||||||
|
|
||||||
|
FILE: ${filePath}
|
||||||
|
ERROR: ${errorMessage}
|
||||||
|
|
||||||
|
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
|
||||||
|
\`\`\`typescript
|
||||||
|
${codeContext.content}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Respond with ONLY the fix JSON in this exact format:
|
||||||
|
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Keep the fix minimal and precise
|
||||||
|
- Only change what's necessary to fix the error
|
||||||
|
- If the error is a missing import, suggest the import statement
|
||||||
|
- If the error is a type mismatch, suggest the corrected code
|
||||||
|
- confidence < 0.7 will not be auto-applied
|
||||||
|
- If you cannot determine a fix, set confidence to 0`
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFixResponse(text) {
|
||||||
|
try {
|
||||||
|
// Extract JSON from response (it might have markdown fences)
|
||||||
|
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
|
||||||
|
if (!jsonMatch) return null
|
||||||
|
return JSON.parse(jsonMatch[0])
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFix(filePath, fixSuggestion) {
|
||||||
|
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const original = readFileSync(filePath, "utf-8")
|
||||||
|
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
|
||||||
|
return { applied: true, backup: original }
|
||||||
|
} catch (e) {
|
||||||
|
return { applied: false, reason: e.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Breaking Change Escalation ─────────────────────────────────────
|
||||||
|
|
||||||
|
function isBreakingChange(errors, fixes) {
|
||||||
|
// Heuristic: if fix touches import structure or exports, it's breaking
|
||||||
|
for (const fix of fixes) {
|
||||||
|
if (!fix || !fix.fix) continue
|
||||||
|
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function escalateBreakingChange(errors, fixes) {
|
||||||
|
const logFile = resolve(ROOT, "repair-escalation.log")
|
||||||
|
const timestamp = new Date().toISOString()
|
||||||
|
const entry = [
|
||||||
|
`=== Breaking Change Escalation @ ${timestamp} ===`,
|
||||||
|
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
|
||||||
|
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
|
||||||
|
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
|
||||||
|
"========================================\n",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
appendFileSync(logFile, entry)
|
||||||
|
log(`Breaking change escalated — see ${logFile}`, "warn")
|
||||||
|
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
|
||||||
|
|
||||||
|
// Wait for timeout or user intervention
|
||||||
|
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
|
||||||
|
log("Escalation timeout reached — applying fixes")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Git Auto-Commit ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function gitCommit(message) {
|
||||||
|
try {
|
||||||
|
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
|
||||||
|
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
|
||||||
|
log(`Committed: [bot] ${message}`)
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
log(`Git commit failed: ${e.message}`, "warn")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Repair Loop ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function repairOnce(doCommit = false) {
|
||||||
|
log("Running build check...")
|
||||||
|
const build = runTsc()
|
||||||
|
if (build.ok) {
|
||||||
|
log("No build errors found")
|
||||||
|
return { fixed: false, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = parseErrors(build.output)
|
||||||
|
if (errors.length === 0) {
|
||||||
|
log("Could not parse build errors from output", "warn")
|
||||||
|
log(build.output.slice(0, 1000))
|
||||||
|
return { fixed: false, errors: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Found ${errors.length} error(s):`)
|
||||||
|
for (const e of errors.slice(0, 10)) {
|
||||||
|
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
|
||||||
|
log(`${shortFile}:${e.line}:${e.column} — ${e.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fixes = []
|
||||||
|
for (const error of errors) {
|
||||||
|
const context = readContext(error.file, error.line)
|
||||||
|
if (!context) {
|
||||||
|
log(`Cannot read ${error.file}`, "error")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
|
||||||
|
try {
|
||||||
|
const prompt = buildRepairPrompt(error.file, error.message, context)
|
||||||
|
const response = await queryOllama(prompt)
|
||||||
|
const fix = parseFixResponse(response)
|
||||||
|
|
||||||
|
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
|
||||||
|
const result = applyFix(error.file, fix)
|
||||||
|
if (result.applied) {
|
||||||
|
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
|
||||||
|
fixes.push(fix)
|
||||||
|
} else {
|
||||||
|
log(`Failed to apply fix: ${result.reason}`, "error")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`Ollama error: ${e.message}`, "error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fixes.length === 0) {
|
||||||
|
log("No fixes could be applied", "error")
|
||||||
|
return { fixed: false, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify by re-running build
|
||||||
|
log("Verifying fixes...")
|
||||||
|
const verify = runTsc()
|
||||||
|
if (verify.ok) {
|
||||||
|
log("All fixes verified — build passes!")
|
||||||
|
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
|
||||||
|
return { fixed: true, errors: [], fixes }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingErrors = parseErrors(verify.output)
|
||||||
|
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
|
||||||
|
|
||||||
|
// Check for breaking changes
|
||||||
|
if (isBreakingChange(remainingErrors, fixes)) {
|
||||||
|
log("Breaking change detected — escalating", "warn")
|
||||||
|
const approved = await escalateBreakingChange(remainingErrors, fixes)
|
||||||
|
if (approved) {
|
||||||
|
if (doCommit) gitCommit(`Breaking change applied after escalation`)
|
||||||
|
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
|
||||||
|
for (let i = 0; i < maxIterations; i++) {
|
||||||
|
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
|
||||||
|
const result = await repairOnce(doCommit)
|
||||||
|
if (!result.fixed) {
|
||||||
|
if (result.errors?.length > 0) {
|
||||||
|
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (result.errors?.length === 0) break
|
||||||
|
}
|
||||||
|
log("Repair agent finished")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Watch Mode ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function watchMode() {
|
||||||
|
log("Watch mode active — monitoring src/ for changes...", "info")
|
||||||
|
const chokidar = (() => {
|
||||||
|
try {
|
||||||
|
return _require("chokidar")
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!chokidar) {
|
||||||
|
log("chokidar not available — using simple polling (5s interval)", "warn")
|
||||||
|
setInterval(async () => {
|
||||||
|
const result = await repairOnce(false)
|
||||||
|
if (result.fixed) log("Auto-fix applied")
|
||||||
|
}, 5000)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
|
||||||
|
let debounceTimer = null
|
||||||
|
|
||||||
|
watcher.on("change", async (filePath) => {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(async () => {
|
||||||
|
log(`Change detected: ${filePath}`)
|
||||||
|
await repairLoop(3, false)
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
log("Watching for changes...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
const isWatch = args.includes("--watch") || args.includes("-w")
|
||||||
|
const isCI = args.includes("--ci") || args.includes("-c")
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (isWatch) {
|
||||||
|
watchMode()
|
||||||
|
} else {
|
||||||
|
await repairLoop(MAX_ITERATIONS, isCI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("Repair agent crashed:", e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// ── Module Generator ─────────────────────────────────────────────────
|
||||||
|
// Generates missing internal modules from templates.
|
||||||
|
// Supports built-in templates and custom .setup-templates/ directory.
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
|
||||||
|
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
|
||||||
|
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
|
||||||
|
|
||||||
|
export interface TemplateRegistry {
|
||||||
|
[importPath: string]: {
|
||||||
|
template: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
generator?: string; // optional custom generator function name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationResult {
|
||||||
|
success: boolean;
|
||||||
|
filePath: string;
|
||||||
|
internalPath: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FallbackRegistry {
|
||||||
|
[pattern: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the template registry from .setup-templates/registry.json */
|
||||||
|
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
|
||||||
|
const builtInTemplates: TemplateRegistry = {
|
||||||
|
"data/stickers": { template: "stickers.ts" },
|
||||||
|
"data/constants": { template: "constants.ts" },
|
||||||
|
"lib/utils": { template: "utils.ts" },
|
||||||
|
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
|
||||||
|
};
|
||||||
|
const builtInFallbacks: FallbackRegistry = {
|
||||||
|
"@/data/*": "data-generic.ts",
|
||||||
|
"@/lib/*": "lib-generic.ts",
|
||||||
|
"@/hooks/*": "hook-generic.ts",
|
||||||
|
"@/types/*": "types-generic.ts",
|
||||||
|
"@/components/*": "component-generic.tsx",
|
||||||
|
"@/utils/*": "utils-generic.ts",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(REGISTRY_PATH)) {
|
||||||
|
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
||||||
|
const custom = JSON.parse(content);
|
||||||
|
return {
|
||||||
|
templates: { ...builtInTemplates, ...(custom.templates || {}) },
|
||||||
|
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use built-in
|
||||||
|
}
|
||||||
|
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match an internal path against fallback patterns */
|
||||||
|
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
|
||||||
|
for (const [pattern, template] of Object.entries(fallbacks)) {
|
||||||
|
// Convert glob pattern to regex
|
||||||
|
const regexStr = "^" + pattern
|
||||||
|
.replace(/\//g, "\\/")
|
||||||
|
.replace(/\./g, "\\.")
|
||||||
|
.replace(/\*/g, ".*")
|
||||||
|
.replace(/[$^+(){}[\]|]/g, "\\$&")
|
||||||
|
+ "$";
|
||||||
|
const regex = new RegExp(regexStr);
|
||||||
|
if (regex.test(internalPath)) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a template with parameters */
|
||||||
|
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
|
||||||
|
let result = templateContent;
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
|
||||||
|
result = result.replace(regex, String(value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get template content - first from custom templates dir, then built-in */
|
||||||
|
function getTemplateContent(templateName: string): string {
|
||||||
|
// Check custom templates first
|
||||||
|
const customPath = path.join(TEMPLATES_DIR, templateName);
|
||||||
|
if (fs.existsSync(customPath)) {
|
||||||
|
return fs.readFileSync(customPath, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in templates (embedded)
|
||||||
|
const builtInTemplates: Record<string, string> = {
|
||||||
|
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||||
|
|
||||||
|
export interface Sticker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
svg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stickers: Sticker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "reactions",
|
||||||
|
name: "Reactions",
|
||||||
|
stickers: [
|
||||||
|
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
|
||||||
|
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
|
||||||
|
{ id: "heart", name: "Heart", emoji: "❤️" },
|
||||||
|
{ id: "laugh", name: "Laugh", emoji: "😂" },
|
||||||
|
{ id: "wow", name: "Wow", emoji: "😮" },
|
||||||
|
{ id: "sad", name: "Sad", emoji: "😢" },
|
||||||
|
{ id: "angry", name: "Angry", emoji: "😡" },
|
||||||
|
{ id: "clap", name: "Clap", emoji: "👏" },
|
||||||
|
{ id: "fire", name: "Fire", emoji: "🔥" },
|
||||||
|
{ id: "100", name: "100", emoji: "💯" },
|
||||||
|
{ id: "eyes", name: "Eyes", emoji: "👀" },
|
||||||
|
{ id: "pray", name: "Pray", emoji: "🙏" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "animals",
|
||||||
|
name: "Animals",
|
||||||
|
stickers: [
|
||||||
|
{ id: "cat", name: "Cat", emoji: "🐱" },
|
||||||
|
{ id: "dog", name: "Dog", emoji: "🐶" },
|
||||||
|
{ id: "panda", name: "Panda", emoji: "🐼" },
|
||||||
|
{ id: "fox", name: "Fox", emoji: "🦊" },
|
||||||
|
{ id: "bear", name: "Bear", emoji: "🐻" },
|
||||||
|
{ id: "koala", name: "Koala", emoji: "🐨" },
|
||||||
|
{ id: "tiger", name: "Tiger", emoji: "🐯" },
|
||||||
|
{ id: "lion", name: "Lion", emoji: "🦁" },
|
||||||
|
{ id: "monkey", name: "Monkey", emoji: "🐵" },
|
||||||
|
{ id: "frog", name: "Frog", emoji: "🐸" },
|
||||||
|
{ id: "penguin", name: "Penguin", emoji: "🐧" },
|
||||||
|
{ id: "bird", name: "Bird", emoji: "🐦" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "food",
|
||||||
|
name: "Food & Drink",
|
||||||
|
stickers: [
|
||||||
|
{ id: "pizza", name: "Pizza", emoji: "🍕" },
|
||||||
|
{ id: "burger", name: "Burger", emoji: "🍔" },
|
||||||
|
{ id: "taco", name: "Taco", emoji: "🌮" },
|
||||||
|
{ id: "sushi", name: "Sushi", emoji: "🍣" },
|
||||||
|
{ id: "ramen", name: "Ramen", emoji: "🍜" },
|
||||||
|
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
|
||||||
|
{ id: "cake", name: "Cake", emoji: "🎂" },
|
||||||
|
{ id: "coffee", name: "Coffee", emoji: "☕" },
|
||||||
|
{ id: "beer", name: "Beer", emoji: "🍺" },
|
||||||
|
{ id: "wine", name: "Wine", emoji: "🍷" },
|
||||||
|
{ id: "donut", name: "Donut", emoji: "🍩" },
|
||||||
|
{ id: "cookie", name: "Cookie", emoji: "🍪" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return [
|
||||||
|
// reactions, animals, food packs defined above
|
||||||
|
// (inline for brevity - full packs in template file)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
export const APP_NAME = "CoastIT CRM";
|
||||||
|
export const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export const LEAD_STATUSES = {
|
||||||
|
new: { label: "New", color: "bg-blue-500" },
|
||||||
|
contacted: { label: "Contacted", color: "bg-yellow-500" },
|
||||||
|
qualified: { label: "Qualified", color: "bg-purple-500" },
|
||||||
|
proposal: { label: "Proposal", color: "bg-orange-500" },
|
||||||
|
won: { label: "Won", color: "bg-green-500" },
|
||||||
|
lost: { label: "Lost", color: "bg-red-500" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
super_admin: { label: "Super Admin", level: 1 },
|
||||||
|
admin: { label: "Admin", level: 2 },
|
||||||
|
sales: { label: "Sales", level: 3 },
|
||||||
|
dev: { label: "Developer", level: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"meeting",
|
||||||
|
"call",
|
||||||
|
"email",
|
||||||
|
"task",
|
||||||
|
"note",
|
||||||
|
] as const;
|
||||||
|
`,
|
||||||
|
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
role: UserRole;
|
||||||
|
active: boolean;
|
||||||
|
avatar: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
contactName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
assignedUserId: string | null;
|
||||||
|
assignedUser: User | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalLeads: number;
|
||||||
|
newLeads: number;
|
||||||
|
contactedLeads: number;
|
||||||
|
qualifiedLeads: number;
|
||||||
|
proposalLeads: number;
|
||||||
|
wonLeads: number;
|
||||||
|
lostLeads: number;
|
||||||
|
conversionRate: number;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return builtInTemplates[templateName] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a single missing module */
|
||||||
|
export function generateModule(
|
||||||
|
internalPath: string,
|
||||||
|
templates: TemplateRegistry,
|
||||||
|
fallbacks: FallbackRegistry
|
||||||
|
): GenerationResult {
|
||||||
|
let entry = templates[internalPath];
|
||||||
|
let templateFile = entry?.template;
|
||||||
|
|
||||||
|
// Try fallback patterns
|
||||||
|
if (!entry) {
|
||||||
|
const fallbackTemplate = matchFallback(internalPath, fallbacks);
|
||||||
|
if (fallbackTemplate) {
|
||||||
|
templateFile = fallbackTemplate;
|
||||||
|
entry = { template: fallbackTemplate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templateFile) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath: "",
|
||||||
|
internalPath,
|
||||||
|
error: `No template registered for @/${internalPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateContent = getTemplateContent(templateFile);
|
||||||
|
if (!templateContent) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath: "",
|
||||||
|
internalPath,
|
||||||
|
error: `Template not found: ${entry.template}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = renderTemplate(templateContent, entry.params || {});
|
||||||
|
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, rendered, "utf-8");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
internalPath,
|
||||||
|
message: `Generated @/${internalPath}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
internalPath,
|
||||||
|
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate all missing modules for a list of internal paths */
|
||||||
|
export function generateAll(internalPaths: string[]): GenerationResult[] {
|
||||||
|
const { templates, fallbacks } = loadRegistry();
|
||||||
|
const results: GenerationResult[] = [];
|
||||||
|
|
||||||
|
for (const internalPath of internalPaths) {
|
||||||
|
const result = generateModule(internalPath, templates, fallbacks);
|
||||||
|
results.push(result);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✓ ${result.message}`);
|
||||||
|
} else {
|
||||||
|
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ── TypeScript Error Parser ──────────────────────────────────────────
|
||||||
|
// Parses `tsc --noEmit` or `npm run build` output to extract
|
||||||
|
// "Module not found: Can't resolve '@/path/to/module'" errors.
|
||||||
|
// Returns actionable missing module info for the generator.
|
||||||
|
|
||||||
|
export interface MissingModule {
|
||||||
|
/** The import path that failed, e.g. "@/data/stickers" */
|
||||||
|
importPath: string;
|
||||||
|
/** The file that contains the failing import */
|
||||||
|
importerFile: string;
|
||||||
|
/** Line number in importer file */
|
||||||
|
line: number;
|
||||||
|
/** Column number */
|
||||||
|
column: number;
|
||||||
|
/** Full error message for context */
|
||||||
|
message: string;
|
||||||
|
/** Whether this is an internal (@/...) module we can generate */
|
||||||
|
isInternal: boolean;
|
||||||
|
/** The normalized internal path, e.g. "data/stickers" */
|
||||||
|
internalPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse TypeScript compiler output for missing module errors */
|
||||||
|
export function parseMissingModules(tscOutput: string): MissingModule[] {
|
||||||
|
const results: MissingModule[] = [];
|
||||||
|
|
||||||
|
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
|
||||||
|
// File: src/components/chats/media-picker.tsx:10:1
|
||||||
|
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = errorRegex.exec(tscOutput)) !== null) {
|
||||||
|
const importPath = match[1];
|
||||||
|
const importerFile = match[2] || "";
|
||||||
|
const line = parseInt(match[3] || "0", 10);
|
||||||
|
const column = parseInt(match[4] || "0", 10);
|
||||||
|
|
||||||
|
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
|
||||||
|
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
|
||||||
|
let altMatch;
|
||||||
|
let altImporter = importerFile;
|
||||||
|
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
|
||||||
|
if (altMatch[1] === importPath) {
|
||||||
|
altImporter = altMatch[2];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
|
||||||
|
|
||||||
|
let internalPath: string | undefined;
|
||||||
|
if (isInternal) {
|
||||||
|
internalPath = importPath.replace(/^[@~]\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
importPath,
|
||||||
|
importerFile: altImporter,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
message: `Cannot find module '${importPath}'`,
|
||||||
|
isInternal,
|
||||||
|
internalPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by importPath + importerFile
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return results.filter((m) => {
|
||||||
|
const key = `${m.importPath}|${m.importerFile}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter to only internal (@/...) modules we can auto-generate */
|
||||||
|
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
|
||||||
|
return modules.filter((m) => m.isInternal && m.internalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group missing modules by internal path */
|
||||||
|
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
|
||||||
|
const groups = new Map<string, MissingModule[]>();
|
||||||
|
for (const m of modules) {
|
||||||
|
if (!m.internalPath) continue;
|
||||||
|
const existing = groups.get(m.internalPath) || [];
|
||||||
|
existing.push(m);
|
||||||
|
groups.set(m.internalPath, existing);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
+195
-16
@@ -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,41 +44,216 @@ 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 args = process.argv.slice(2)
|
||||||
|
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
|
||||||
|
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. Self-healing build check (--self-heal flag)
|
||||||
console.log("\n── Next steps ──")
|
if (doSelfHeal) {
|
||||||
|
console.log("\n── Self-Healing Build Check ──")
|
||||||
|
const { generateModule } = loadRegistry()
|
||||||
|
let lastGeneratedCount = -1
|
||||||
|
let iteration = 0
|
||||||
|
const MAX_ITERATIONS = 5
|
||||||
|
|
||||||
|
while (iteration < MAX_ITERATIONS) {
|
||||||
|
iteration++
|
||||||
|
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
|
||||||
|
|
||||||
|
let buildOutput = ""
|
||||||
|
try {
|
||||||
|
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
|
||||||
|
} catch (e) {
|
||||||
|
buildOutput = e.stdout?.toString() || e.message || ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing = parseMissingModules(buildOutput)
|
||||||
|
const internalMissing = missing
|
||||||
|
.filter(m => m.isInternal && m.internalPath)
|
||||||
|
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
|
||||||
|
|
||||||
|
if (internalMissing.length === 0) {
|
||||||
|
console.log(" ✓ No missing internal modules found!")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Found ${internalMissing.length} missing internal module(s)`)
|
||||||
|
|
||||||
|
let generated = 0
|
||||||
|
for (const mod of internalMissing) {
|
||||||
|
const result = generateModule(mod.internalPath)
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✓ ${result.message}`)
|
||||||
|
generated++
|
||||||
|
} else {
|
||||||
|
console.log(` ✗ ${result.error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (generated === 0 || generated === lastGeneratedCount) {
|
||||||
|
console.log(" No new modules could be generated. Stopping.")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastGeneratedCount = generated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Final summary
|
||||||
|
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")
|
||||||
|
|||||||
@@ -1,82 +1,134 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useCallback } from "react"
|
import { useState, useCallback, useRef, useEffect } from "react"
|
||||||
import { AIChat } from "@/components/ai/ai-chat"
|
import { AIChat } 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"
|
||||||
|
|
||||||
|
const tips = [
|
||||||
|
"Ask for cold email templates for a specific job",
|
||||||
|
"Request objection handling tips",
|
||||||
|
"Ask for outreach strategies per industry",
|
||||||
|
"Generate a follow up sequence",
|
||||||
|
"Get LinkedIn connection message templates",
|
||||||
|
]
|
||||||
|
|
||||||
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 aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null)
|
||||||
|
|
||||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||||
setSelectedJob(job)
|
setSelectedJob(job)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleTipClick = useCallback((tip: string) => {
|
||||||
|
aiChatRef.current?.fillInput(tip)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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-[#0f1117]">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
<div className="bg-[#1a1d2e]/80 backdrop-blur-md border-b border-[#ffffff08] 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-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
|
||||||
|
<Bot className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white font-bold text-lg">AI Sales Assistant</h1>
|
||||||
|
<p className="text-[#6b7280] text-xs mt-0.5">Powered by local AI</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex items-center gap-4">
|
||||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
|
||||||
|
<span className="text-[#22c55e] text-xs font-medium">Online</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-5 bg-[#ffffff08]" />
|
||||||
|
<div className="bg-[#ffffff08] rounded-lg px-3 py-1.5">
|
||||||
|
<span className="text-[#9ca3af] text-xs">Local AI Model</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
||||||
<div className="flex-1 flex">
|
</div>
|
||||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
<div className="w-[300px] flex-none bg-[#13151f] border-l border-[#ffffff08] p-5 overflow-y-auto h-full flex flex-col">
|
||||||
<AIChat />
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
||||||
|
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||||
</div>
|
</div>
|
||||||
|
<JobSelector onSelect={handleJobSelect} />
|
||||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
{selectedJob && (
|
||||||
<div>
|
<div className="bg-[#1a1d2e]/50 border border-[#ffffff08] rounded-xl p-3.5 mt-3 space-y-2">
|
||||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
<h4 className="text-sm font-semibold text-[#e5e7eb]">{selectedJob.job_title}</h4>
|
||||||
<Target className="h-3.5 w-3.5" />
|
<span className="text-xs px-2 py-0.5 rounded-md bg-[#f97316]/10 text-[#f97316] font-medium inline-block">{selectedJob.industry}</span>
|
||||||
Target Job
|
<p className="text-xs text-[#6b7280] leading-relaxed">{selectedJob.description}</p>
|
||||||
</h3>
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<JobSelector onSelect={handleJobSelect} />
|
{selectedJob.keywords.map((kw, i) => (
|
||||||
</div>
|
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-[#ffffff08] text-[#4b5563]">{kw}</span>
|
||||||
|
))}
|
||||||
{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>
|
||||||
|
)}
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
</div>
|
||||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
<div className="mt-6">
|
||||||
<Lightbulb className="h-3.5 w-3.5" />
|
<div className="flex items-center gap-2 mb-3">
|
||||||
Tips
|
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
||||||
</h4>
|
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
|
||||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
</div>
|
||||||
<li className="flex gap-2">
|
{tips.map((tip, i) => (
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
<div
|
||||||
Ask for cold email templates for a specific job
|
key={i}
|
||||||
</li>
|
onClick={() => handleTipClick(tip)}
|
||||||
<li className="flex gap-2">
|
className="flex items-start gap-2.5 bg-[#1a1d2e]/50 hover:bg-[#1a1d2e] border border-[#ffffff08] hover:border-[#f97316]/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
>
|
||||||
Request objection handling tips
|
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-[#374151] group-hover:text-[#f97316] transition-colors duration-200 flex-shrink-0" />
|
||||||
</li>
|
<span className="text-[#9ca3af] text-xs leading-5 group-hover:text-[#e5e7eb] transition-colors duration-200">{tip}</span>
|
||||||
<li className="flex gap-2">
|
</div>
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
))}
|
||||||
Ask for outreach strategies per industry
|
</div>
|
||||||
</li>
|
<div className="mt-6">
|
||||||
</ul>
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
|
||||||
|
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
|
||||||
|
</div>
|
||||||
|
{recentPrompts.length > 0 ? (
|
||||||
|
recentPrompts.map((prompt, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
onClick={() => handleRecentPromptClick(prompt)}
|
||||||
|
className="bg-[#1a1d2e]/50 rounded-xl p-3 mb-2 border border-[#ffffff08] text-[#6b7280] text-xs truncate hover:text-[#9ca3af] cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-[#374151] text-xs text-center py-4">Your recent prompts will appear here</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto border-t border-[#ffffff08] pt-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Messages today</div>
|
||||||
|
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">24</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Tokens used</div>
|
||||||
|
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">12.4k</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function GET() {
|
|||||||
[user.id],
|
[user.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const websiteTheme = result.rows[0]?.website_theme || "spidey"
|
const websiteTheme = result.rows[0]?.website_theme || "default"
|
||||||
return NextResponse.json({ websiteTheme })
|
return NextResponse.json({ websiteTheme })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Website theme GET error:", error)
|
console.error("Website theme GET error:", error)
|
||||||
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const theme = body.websiteTheme || "spidey"
|
const theme = body.websiteTheme || "default"
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -734,3 +736,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); }
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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"
|
||||||
|
|
||||||
const inter = Inter({
|
const inter = Inter({
|
||||||
|
|||||||
+244
-152
@@ -1,33 +1,80 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, Fragment } from "react"
|
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
|
||||||
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
|
import { Bot, Terminal } from "lucide-react"
|
||||||
|
|
||||||
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-[#f97316] hover:text-[#f97316]/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-[#f97316] 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
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AIChat() {
|
interface AIChatProps {
|
||||||
|
onMessageSent?: (msg: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickActions = [
|
||||||
|
{ icon: "✉️", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Cold Email Template", desc: "Generate targeted outreach emails", prompt: "Write a cold email template for a Software Developer" },
|
||||||
|
{ icon: "🛡️", iconBg: "bg-[#3b82f6]/10", iconColor: "text-[#3b82f6]", title: "Handle Objections", desc: "Get scripts for common objections", prompt: "Give me objection handling scripts for sales" },
|
||||||
|
{ icon: "🎯", iconBg: "bg-[#8b5cf6]/10", iconColor: "text-[#8b5cf6]", title: "Target Industry", desc: "Find leads in specific industries", prompt: "How do I target leads in the tech industry" },
|
||||||
|
{ icon: "📋", iconBg: "bg-[#22c55e]/10", iconColor: "text-[#22c55e]", title: "Build Lead List", desc: "Strategies to grow your pipeline", prompt: "Help me build a lead list strategy" },
|
||||||
|
{ icon: "📞", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Call Scripts", desc: "Proven phone sales scripts", prompt: "Give me a cold call script for outreach" },
|
||||||
|
{ icon: "📊", iconBg: "bg-[#ec4899]/10", iconColor: "text-[#ec4899]", title: "Sales Strategy", desc: "Industry specific sales tactics", prompt: "What sales strategies work best per industry" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const commandPills = [
|
||||||
|
{ icon: "📋", label: "Lists", prompt: "lists" },
|
||||||
|
{ icon: "👥", label: "Leads", prompt: "leads" },
|
||||||
|
{ icon: "💡", label: "Tips", prompt: "Give me some sales tips" },
|
||||||
|
{ icon: "✉️", label: "Templates", prompt: "Show me email templates" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AIChat = forwardRef<{ fillInput: (text: string) => void }, 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 [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const hasUserMessage = messages.some(m => m.role === "user")
|
||||||
|
|
||||||
const checkServer = async () => {
|
useImperativeHandle(ref, () => ({
|
||||||
|
fillInput(text: string) {
|
||||||
|
setInput(text)
|
||||||
|
setTimeout(() => textareaRef.current?.focus(), 50)
|
||||||
|
},
|
||||||
|
}), [])
|
||||||
|
|
||||||
|
const checkServer = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/ai/chat", {
|
const res = await fetch("/api/ai/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -42,7 +89,7 @@ export function AIChat() {
|
|||||||
} catch {
|
} catch {
|
||||||
setTimeout(checkServer, 2000)
|
setTimeout(checkServer, 2000)
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch("/api/ai/jobs")
|
fetch("/api/ai/jobs")
|
||||||
@@ -74,18 +121,19 @@ export function AIChat() {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
checkServer()
|
checkServer()
|
||||||
}, [])
|
}, [checkServer])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [messages])
|
}, [messages])
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = useCallback(async (text?: string) => {
|
||||||
const msg = input.trim()
|
const msg = (text || 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)
|
||||||
|
|
||||||
@@ -113,167 +161,211 @@ 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"
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleQuickAction = useCallback((prompt: string) => {
|
||||||
|
setInput(prompt)
|
||||||
|
setTimeout(() => sendMessage(prompt), 50)
|
||||||
|
}, [sendMessage])
|
||||||
|
|
||||||
|
const handleCommandPill = useCallback((prompt: string) => {
|
||||||
|
setInput(prompt)
|
||||||
|
setTimeout(() => sendMessage(prompt), 50)
|
||||||
|
}, [sendMessage])
|
||||||
|
|
||||||
|
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-[#6b7280] animate-pulse">Servers booting...</p>
|
||||||
|
<div className="h-[56px] w-[110px] bg-[#1a1d2e] border border-[#ffffff0f] 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="#f97316" opacity="0.9"/>
|
||||||
|
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#f97316" opacity="0.7"/>
|
||||||
|
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#f97316" opacity="0.7"/>
|
||||||
|
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
|
||||||
|
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
|
||||||
|
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
|
||||||
|
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
|
||||||
|
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0f1117"/>
|
||||||
|
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#f97316" opacity="0.8"/>
|
||||||
|
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#f97316" opacity="0.8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const bootOverlay = (
|
if (bootState === "ready" && !hasUserMessage) {
|
||||||
<div className="flex-1 flex items-center justify-center">
|
return (
|
||||||
<style>{`
|
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||||
@keyframes walk {
|
<div className="flex-1 flex items-center justify-center">
|
||||||
0%, 100% { transform: translateX(0) translateY(0); }
|
<div className="max-w-lg mx-auto pt-12 pb-6 px-4">
|
||||||
25% { transform: translateX(12px) translateY(-4px); }
|
<div className="float-in">
|
||||||
50% { transform: translateX(24px) translateY(0); }
|
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
|
||||||
75% { transform: translateX(12px) translateY(-4px); }
|
<Bot className="h-8 w-8 text-white" />
|
||||||
}
|
</div>
|
||||||
@keyframes legLeft {
|
<h2 className="text-white font-bold text-2xl text-center mb-2">What can I help you with?</h2>
|
||||||
0%, 100% { transform: rotate(-10deg); }
|
<p className="text-[#6b7280] text-sm text-center mb-8 leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
|
||||||
50% { transform: rotate(10deg); }
|
</div>
|
||||||
}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@keyframes legRight {
|
{quickActions.map((action, i) => (
|
||||||
0%, 100% { transform: rotate(10deg); }
|
<div
|
||||||
50% { transform: rotate(-10deg); }
|
key={i}
|
||||||
}
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
@keyframes armLeft {
|
className="float-in bg-[#1a1d2e] hover:bg-[#1f2437] rounded-xl p-4 border border-[#ffffff0a] hover:border-[#f97316]/30 cursor-pointer transition-all duration-200 hover:shadow-[0_4px_20px_rgba(249,115,22,0.1)] hover:-translate-y-0.5"
|
||||||
0%, 100% { transform: rotate(15deg); }
|
style={{ animationDelay: `${0.1 + i * 0.05}s` }}
|
||||||
50% { transform: rotate(-15deg); }
|
>
|
||||||
}
|
<div className={`w-9 h-9 rounded-lg ${action.iconBg} ${action.iconColor} flex items-center justify-center text-lg`}>{action.icon}</div>
|
||||||
@keyframes armRight {
|
<h3 className="text-white text-sm font-medium mt-3">{action.title}</h3>
|
||||||
0%, 100% { transform: rotate(-15deg); }
|
<p className="text-[#6b7280] text-xs mt-1">{action.desc}</p>
|
||||||
50% { transform: rotate(15deg); }
|
</div>
|
||||||
}
|
))}
|
||||||
@keyframes blink {
|
</div>
|
||||||
0%, 45%, 55%, 100% { height: 4px; }
|
<div className="flex gap-2 justify-center mt-6 flex-wrap">
|
||||||
50% { height: 1px; }
|
{commandPills.map((pill, i) => (
|
||||||
}
|
<div
|
||||||
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
key={i}
|
||||||
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
onClick={() => handleCommandPill(pill.prompt)}
|
||||||
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
className="bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/40 hover:bg-[#1f2437] rounded-full px-4 py-2 text-xs text-[#9ca3af] hover:text-[#f97316] transition-all duration-200 cursor-pointer flex items-center gap-1.5"
|
||||||
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
>
|
||||||
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
<span>{pill.icon}</span>
|
||||||
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
<span>{pill.label}</span>
|
||||||
`}</style>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-4">
|
))}
|
||||||
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
|
</div>
|
||||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
|
</div>
|
||||||
<div className="robot-walk relative">
|
</div>
|
||||||
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
|
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
|
||||||
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
|
<div className="max-w-3xl mx-auto">
|
||||||
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
|
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
|
||||||
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
|
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
|
||||||
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
<textarea
|
||||||
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
|
ref={textareaRef}
|
||||||
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
value={input}
|
||||||
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
|
onChange={(e) => setInput(e.target.value)}
|
||||||
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
|
onInput={handleTextareaInput}
|
||||||
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
|
onKeyDown={handleKeyDown}
|
||||||
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
|
placeholder="Ask for sales tips..."
|
||||||
</svg>
|
rows={1}
|
||||||
|
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] 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-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,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-[#374151] text-[10px]">Shift + Enter for new line</span>
|
||||||
|
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
const readyOverlay = (
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="flex flex-col items-center gap-4">
|
|
||||||
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
|
|
||||||
<Check className="h-6 w-6 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div className="text-center space-y-1">
|
|
||||||
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
|
|
||||||
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (bootState === "booting") return bootOverlay
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
{bootState === "ready" && readyOverlay}
|
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
|
||||||
|
<div className="max-w-3xl mx-auto space-y-6">
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
|
{messages.map((msg, i) => (
|
||||||
{messages.map((msg, i) => (
|
<div key={i}>
|
||||||
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
|
{msg.role === "assistant" ? (
|
||||||
{msg.role === "assistant" && (
|
<div className="flex gap-3 items-start">
|
||||||
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
|
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||||
<Bot className="h-4 w-4 text-[#1BB0CE]" />
|
<Bot className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 max-w-[85%]">
|
||||||
|
<div className="text-[#e5e7eb] text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-[#4b5563] 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-[#f97316] to-[#ea580c] rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
|
||||||
|
<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-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
|
||||||
|
<Bot className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 inline-flex items-center gap-1.5">
|
||||||
<div
|
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-1" />
|
||||||
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
|
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-2" />
|
||||||
msg.role === "user"
|
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-3" />
|
||||||
? "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>
|
||||||
)}
|
|
||||||
</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>
|
||||||
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
|
)}
|
||||||
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
|
<div ref={messagesEndRef} />
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
</div>
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={messagesEndRef} />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] 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-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
|
||||||
|
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
|
||||||
|
<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-[#e5e7eb] text-sm placeholder-[#4b5563] 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-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,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-[#374151] text-[10px]">Shift + Enter for new line</span>
|
||||||
|
<span className="text-[#374151] 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 ? (
|
|
||||||
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
|
|
||||||
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
|
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
) : <Send className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|
||||||
|
AIChat.displayName = "AIChat"
|
||||||
|
|||||||
@@ -39,32 +39,32 @@ 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-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] 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-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] 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-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 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-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/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-[#4b5563] 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-[#4b5563] text-center">No job categories loaded</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const themeOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const backgroundOptions = [
|
const backgroundOptions = [
|
||||||
{ value: "", label: "Default", icon: Palette, color: "bg-transparent", ring: "ring-border", desc: "Clean light/dark mode" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
|
|||||||
const WEBSITE_THEME_KEY = "crm-website-theme"
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
||||||
|
|
||||||
const themeClasses: Record<string, string> = {
|
const themeClasses: Record<string, string> = {
|
||||||
|
default: "theme-default",
|
||||||
spidey: "theme-spidey",
|
spidey: "theme-spidey",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ function storeTheme(theme: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [websiteTheme, setWebsiteThemeState] = useState<string>("")
|
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -52,13 +53,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
|
|||||||
fetch("/api/settings/website-theme")
|
fetch("/api/settings/website-theme")
|
||||||
.then((res) => (res.ok ? res.json() : null))
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
const theme = data?.websiteTheme || ""
|
const theme = data?.websiteTheme || "default"
|
||||||
setWebsiteThemeState(theme)
|
setWebsiteThemeState(theme)
|
||||||
storeTheme(theme)
|
storeTheme(theme)
|
||||||
applyWebsiteTheme(theme)
|
applyWebsiteTheme(theme)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
applyWebsiteTheme("")
|
applyWebsiteTheme("default")
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
AI Assistant Page — Animations & Keyframes
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Robot walking animation (boot state) */
|
||||||
|
@keyframes walk {
|
||||||
|
0%, 100% { transform: translateX(0) translateY(0); }
|
||||||
|
25% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
50% { transform: translateX(24px) translateY(0); }
|
||||||
|
75% { transform: translateX(12px) translateY(-4px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes legLeft {
|
||||||
|
0%, 100% { transform: rotate(-10deg); }
|
||||||
|
50% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes legRight {
|
||||||
|
0%, 100% { transform: rotate(10deg); }
|
||||||
|
50% { transform: rotate(-10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes armLeft {
|
||||||
|
0%, 100% { transform: rotate(15deg); }
|
||||||
|
50% { transform: rotate(-15deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes armRight {
|
||||||
|
0%, 100% { transform: rotate(-15deg); }
|
||||||
|
50% { transform: rotate(15deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 45%, 55%, 100% { height: 4px; }
|
||||||
|
50% { height: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
|
||||||
|
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
|
||||||
|
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
|
||||||
|
.robot-eye { animation: blink 2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* Welcome screen float-in animation */
|
||||||
|
@keyframes float-in {
|
||||||
|
0% { opacity: 0; transform: translateY(12px); }
|
||||||
|
100% { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.float-in { animation: float-in 0.4s ease-out both; }
|
||||||
|
|
||||||
|
/* Typing indicator bouncing dots */
|
||||||
|
@keyframes bounce-dot {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-6px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-1 { animation: bounce-dot 1.2s infinite 0ms; }
|
||||||
|
.dot-2 { animation: bounce-dot 1.2s infinite 150ms; }
|
||||||
|
.dot-3 { animation: bounce-dot 1.2s infinite 300ms; }
|
||||||
Reference in New Issue
Block a user