Compare commits
109 Commits
d6534eb338
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d6cdfb01 | |||
| 4035f5fad4 | |||
| dbec6c0851 | |||
| cfc69d47d3 | |||
| 51ec96ce7b | |||
| 7820896a35 | |||
| a446a19760 | |||
| dd56014318 | |||
| 9df7475f35 | |||
| 12115644e9 | |||
| d1c62d940f | |||
| a7f3058668 | |||
| a1fef1d8bb | |||
| 5d315bed22 | |||
| abcef070c3 | |||
| 211d026fb8 | |||
| dd6767980a | |||
| 4c732c850f | |||
| aabdbf9b91 | |||
| fc099c6d00 | |||
| c1c4afadbb | |||
| 37679a7a60 | |||
| 0a4988156f | |||
| c0cb715ced | |||
| 53ae0944be | |||
| 956e2697c9 | |||
| 4a33ca0368 | |||
| 7606af04ab | |||
| 26319281d4 | |||
| 183d6c629d | |||
| 6f622b6329 | |||
| ef2e12ec5e | |||
| a02f013f38 | |||
| 2979078edd | |||
| 3fe32d923e | |||
| 1269f6cce8 | |||
| 727dcddf37 | |||
| 370f935cbb | |||
| 9bfd4e47ee | |||
| 00bda54695 | |||
| 963ea1d1b8 | |||
| eef290a20a | |||
| faf9dd551a | |||
| f67d9377a0 | |||
| 38fb3a47a8 | |||
| bc422edcf7 | |||
| ef75d16bf4 | |||
| da5f8360bc | |||
| 37af1febcc | |||
| 9f4dd4fc6b | |||
| bcd2d73e70 | |||
| 84632e043f | |||
| b2bb6d94f5 | |||
| 566fa3df88 | |||
| 16710e3019 | |||
| e920d4925a | |||
| 72b839f5d4 | |||
| cb1944a9ea | |||
| e8d80c3a16 | |||
| 48f78cf2ca | |||
| c1745df329 | |||
| 77cccba8a1 | |||
| 72f872b2f9 | |||
| 838ec9617a | |||
| b75351112e | |||
| 6016ab2855 | |||
| 545065b527 | |||
| 5d971dc749 | |||
| eedf528a33 | |||
| 8ef283a528 | |||
| 4eba6fe476 | |||
| feb2549373 | |||
| 1e4950009d | |||
| 7a5e833722 | |||
| db487e4614 | |||
| 4b2ea3db2a | |||
| 1e8f979cf9 | |||
| 98145f0264 | |||
| 1d080761fb | |||
| 7a722a7d7e | |||
| 61e9fac352 | |||
| 878627a6ba | |||
| 2c3a8e5333 | |||
| d870fb3ac7 | |||
| f6d1d91fc6 | |||
| bc3e345a27 | |||
| 54b19123ca | |||
| 3c0e7d76ca | |||
| 39fb39db12 | |||
| cd37d5a987 | |||
| d138c60203 | |||
| 4d7a59c278 | |||
| aac9817ee7 | |||
| fa97abb5b6 | |||
| 3998285fd5 | |||
| 4c1db42873 | |||
| c428435f2f | |||
| 886a7efe91 | |||
| 1b5f244f28 | |||
| 46a3216386 | |||
| 8aba5c9ef4 | |||
| 06181625c3 | |||
| 08eba574f8 | |||
| a876f2d769 | |||
| fbb37d6777 | |||
| 214c4a1852 | |||
| c669ec0d04 | |||
| bf6fbe3ee4 | |||
| bccba1434b |
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,6 +13,13 @@ pnpm dev
|
|||||||
# or
|
# or
|
||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
```bash
|
||||||
|
npm install build
|
||||||
|
# and
|
||||||
|
npm run build
|
||||||
|
# and
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ cd frontend
|
|||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
npm install
|
npm install
|
||||||
|
npm install devenv
|
||||||
|
npm install build
|
||||||
|
|
||||||
# Start dev server
|
# Start dev server
|
||||||
npm run dev
|
npm run dev
|
||||||
@@ -24,6 +26,7 @@ The app runs at **http://localhost:3000**
|
|||||||
| Route | Description |
|
| Route | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| `/login` | Login page |
|
| `/login` | Login page |
|
||||||
|
| `client-portal/login` | Client Login page |
|
||||||
| `/` | Dashboard |
|
| `/` | Dashboard |
|
||||||
| `/leads` | Lead management table |
|
| `/leads` | Lead management table |
|
||||||
| `/leads/:id` | Lead detail view |
|
| `/leads/:id` | Lead detail view |
|
||||||
|
|||||||
@@ -0,0 +1,766 @@
|
|||||||
|
.dark.theme-bw {
|
||||||
|
--background: 0 0% 5%;
|
||||||
|
--foreground: 0 0% 97%;
|
||||||
|
--card: 0 0% 10%;
|
||||||
|
|
||||||
|
--card-foreground: 0 0% 97%;
|
||||||
|
--popover: 0 0% 10%;
|
||||||
|
--popover-foreground: 0 0% 97%;
|
||||||
|
--primary: 0 0% 97%;
|
||||||
|
--primary-foreground: 0 0% 0%;
|
||||||
|
--secondary: 0 0% 68%;
|
||||||
|
--secondary-foreground: 0 0% 0%;
|
||||||
|
--muted: 0 0% 15%;
|
||||||
|
--muted-foreground: 0 0% 72%;
|
||||||
|
--accent: 0 0% 25%;
|
||||||
|
--accent-foreground: 0 0% 97%;
|
||||||
|
--destructive: 0 0% 40%;
|
||||||
|
--destructive-foreground: 0 0% 97%;
|
||||||
|
--border: 0 0% 25%;
|
||||||
|
--input: 0 0% 25%;
|
||||||
|
--ring: 0 0% 70%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 4%;
|
||||||
|
--sidebar-foreground: 0 0% 97%;
|
||||||
|
--sidebar-primary: 0 0% 97%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 0%;
|
||||||
|
--sidebar-accent: 0 0% 15%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 97%;
|
||||||
|
--sidebar-border: 0 0% 18%;
|
||||||
|
--sidebar-ring: 0 0% 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw {
|
||||||
|
--background: 0 0% 97%;
|
||||||
|
--foreground: 0 0% 8%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 0 0% 8%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 0 0% 8%;
|
||||||
|
--primary: 0 0% 8%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 0 0% 55%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 0 0% 93%;
|
||||||
|
--muted-foreground: 0 0% 38%;
|
||||||
|
--accent: 0 0% 20%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 0% 60%;
|
||||||
|
--destructive-foreground: 0 0% 10%;
|
||||||
|
--border: 0 0% 78%;
|
||||||
|
--input: 0 0% 78%;
|
||||||
|
--ring: 0 0% 45%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 0 0% 8%;
|
||||||
|
--sidebar-primary: 0 0% 8%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 0 0% 90%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 8%;
|
||||||
|
--sidebar-border: 0 0% 80%;
|
||||||
|
--sidebar-ring: 0 0% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw body {
|
||||||
|
background: transparent
|
||||||
|
radial-gradient(ellipse 100% 60% at 30% 20%, hsla(0,0%,100%,0.03) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 70% 80%, hsla(0,0%,50%,0.02) 0%, transparent 50%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw body > div {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(1px 1px at 10% 20%, hsla(0,0%,100%,0.12) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 30% 70%, hsla(0,0%,100%,0.08) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 50% 10%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 70% 50%, hsla(0,0%,100%,0.06) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 90% 30%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 15% 85%, hsla(0,0%,100%,0.08) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 65% 90%, hsla(0,0%,100%,0.06) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 85% 15%, hsla(0,0%,100%,0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 40% 40%, hsla(0,0%,100%,0.07) 0%, transparent 100%),
|
||||||
|
radial-gradient(1px 1px at 55% 60%, hsla(0,0%,100%,0.05) 0%, transparent 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: bw-drift 40s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 40% at 20% 30%, hsla(0,0%,100%,0.04) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 70%, hsla(0,0%,50%,0.02) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse 50% 30% at 50% 50%, hsla(0,0%,100%,0.02) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 20% 30%, hsla(0,0%,0%,0.02) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 70%, hsla(0,0%,0%,0.01) 0%, transparent 50%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 60px, hsla(0,0%,0%,0.015) 60px, hsla(0,0%,0%,0.015) 61px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 60px, hsla(0,0%,0%,0.015) 60px, hsla(0,0%,0%,0.015) 61px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bw-drift {
|
||||||
|
0% { background-position: 0% 0%; opacity: 0.5; }
|
||||||
|
50% { background-position: 100% 100%; opacity: 1; }
|
||||||
|
100% { background-position: 0% 100%; opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .text-glow {
|
||||||
|
text-shadow: 0 0 10px hsla(0,0%,100%,0.15), 0 0 30px hsla(0,0%,100%,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .text-glow {
|
||||||
|
text-shadow: 0 0 8px hsla(0,0%,0%,0.08), 0 0 20px hsla(0,0%,0%,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw h1, .dark.theme-bw h2, .dark.theme-bw h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(0, 0%, 90%), hsl(0, 0%, 60%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw h1, .light.theme-bw h2, .light.theme-bw h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(0, 0%, 20%), hsl(0, 0%, 50%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw a {
|
||||||
|
color: hsl(0, 0%, 80%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw a {
|
||||||
|
color: hsl(0, 0%, 30%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw a:hover {
|
||||||
|
color: hsl(0, 0%, 100%);
|
||||||
|
text-shadow: 0 0 8px hsla(0,0%,100%,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw a:hover {
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw blockquote {
|
||||||
|
border-left: 3px solid hsl(0, 0%, 50%);
|
||||||
|
color: hsl(0, 0%, 60%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw blockquote {
|
||||||
|
border-left: 3px solid hsl(0, 0%, 60%);
|
||||||
|
color: hsl(0, 0%, 50%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw code {
|
||||||
|
color: hsl(0, 0%, 80%);
|
||||||
|
background: hsla(0, 0%, 100%, 0.08);
|
||||||
|
border: 1px solid hsla(0, 0%, 100%, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw code {
|
||||||
|
color: hsl(0, 0%, 30%);
|
||||||
|
background: hsla(0, 0%, 0%, 0.04);
|
||||||
|
border: 1px solid hsla(0, 0%, 0%, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw pre {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
border: 1px solid hsla(0, 0%, 100%, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw pre {
|
||||||
|
background: hsla(0, 0%, 0%, 0.02);
|
||||||
|
border: 1px solid hsla(0, 0%, 0%, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw hr {
|
||||||
|
border-color: hsla(0, 0%, 100%, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw hr {
|
||||||
|
border-color: hsla(0, 0%, 0%, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw table th {
|
||||||
|
color: hsl(0, 0%, 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw table th {
|
||||||
|
color: hsl(0, 0%, 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw table td {
|
||||||
|
border-color: hsla(0, 0%, 100%, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw table td {
|
||||||
|
border-color: hsla(0, 0%, 0%, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw ::selection {
|
||||||
|
background: hsla(0, 0%, 100%, 0.15);
|
||||||
|
color: hsl(0, 0%, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw ::selection {
|
||||||
|
background: hsla(0, 0%, 0%, 0.1);
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw input::placeholder,
|
||||||
|
.dark.theme-bw textarea::placeholder {
|
||||||
|
color: hsl(0, 0%, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw input::placeholder,
|
||||||
|
.light.theme-bw textarea::placeholder {
|
||||||
|
color: hsl(0, 0%, 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .page-header-title {
|
||||||
|
color: hsl(0, 0%, 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .page-header-title {
|
||||||
|
color: hsl(0, 0%, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .stat-card-value {
|
||||||
|
color: hsl(0, 0%, 95%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .stat-card-value {
|
||||||
|
color: hsl(0, 0%, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .badge {
|
||||||
|
color: hsl(0, 0%, 80%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .badge {
|
||||||
|
color: hsl(0, 0%, 30%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 0%, 80%), hsl(0, 0%, 95%), hsl(0, 0%, 80%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 70%), hsl(0, 0%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(0, 0%, 100%, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(0, 0%, 50%, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(0, 0%, 95%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(0, 0%, 70%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(0, 0%, 100%, 0.2), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(0, 0%, 50%, 0.15), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||||
|
background: linear-gradient(to right, hsl(0, 0%, 80%), hsl(0, 0%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 0%, 30%), hsl(0, 0%, 50%), hsl(0, 0%, 30%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 0%, 50%), hsl(0, 0%, 65%), hsl(0, 0%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .growth-word {
|
||||||
|
-webkit-text-fill-color: hsl(0, 0%, 70%) !important;
|
||||||
|
color: hsl(0, 0%, 70%) !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw .growth-word {
|
||||||
|
-webkit-text-fill-color: hsl(0, 0%, 40%) !important;
|
||||||
|
color: hsl(0, 0%, 40%) !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Client Portal Overrides — Background for portal pages
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-bw .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(0, 0%, 6%) !important;
|
||||||
|
border-right-color: hsl(0, 0%, 18%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw aside .text-muted-foreground {
|
||||||
|
color: hsl(0, 0%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-bw aside button:hover {
|
||||||
|
background: hsla(0, 0%, 100%, 0.08) !important;
|
||||||
|
color: hsl(0, 0%, 90%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw aside .bg-primary\/10.text-primary {
|
||||||
|
background: hsl(0, 0%, 50%) !important;
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.3), 0 0 20px hsla(0, 0%, 50%, 0.1);
|
||||||
|
border-color: hsl(0, 0%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-bw aside .bg-primary\/10.text-primary svg {
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw header.bg-card\/80 {
|
||||||
|
background: hsl(0, 0%, 6%) !important;
|
||||||
|
border-bottom-color: hsl(0, 0%, 18%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-card {
|
||||||
|
background: hsla(0, 0%, 7%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(0, 0%, 20%, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .border-border {
|
||||||
|
border-color: hsl(0, 0%, 15%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-muted {
|
||||||
|
background: hsl(0, 0%, 9%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-primary {
|
||||||
|
background: hsl(0, 0%, 50%) !important;
|
||||||
|
box-shadow: 0 0 10px hsla(0, 0%, 50%, 0.2);
|
||||||
|
}
|
||||||
|
.dark.theme-bw .bg-primary:hover {
|
||||||
|
background: hsl(0, 0%, 60%) !important;
|
||||||
|
box-shadow: 0 0 15px hsla(0, 0%, 60%, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Dashboard Chart Overrides — donut chart & leads-per-month bar chart
|
||||||
|
Overrides SVG presentation attributes (fill, stop-color, flood-color)
|
||||||
|
Distinguishable greys per segment + glow on hover
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* ---- Donut chart: individual segment fills with varied greys ---- */
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,55%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,35%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,65%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,30%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,70%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-0)"] { fill: hsl(0,0%,40%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-1)"] { fill: hsl(0,0%,55%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-2)"] { fill: hsl(0,0%,30%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-3)"] { fill: hsl(0,0%,60%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-4)"] { fill: hsl(0,0%,45%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#chart-grad-5)"] { fill: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
/* ---- Donut chart: gradient defs stops (match the fills above) ---- */
|
||||||
|
.dark.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,55%) !important; }
|
||||||
|
.dark.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,35%) !important; }
|
||||||
|
.dark.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,65%) !important; }
|
||||||
|
.dark.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,30%) !important; }
|
||||||
|
.dark.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,70%) !important; }
|
||||||
|
.dark.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-bw #chart-grad-0 stop { stop-color: hsl(0,0%,40%) !important; }
|
||||||
|
.light.theme-bw #chart-grad-1 stop { stop-color: hsl(0,0%,55%) !important; }
|
||||||
|
.light.theme-bw #chart-grad-2 stop { stop-color: hsl(0,0%,30%) !important; }
|
||||||
|
.light.theme-bw #chart-grad-3 stop { stop-color: hsl(0,0%,60%) !important; }
|
||||||
|
.light.theme-bw #chart-grad-4 stop { stop-color: hsl(0,0%,45%) !important; }
|
||||||
|
.light.theme-bw #chart-grad-5 stop { stop-color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
/* ---- Bar chart: distinguishable fills for two series ---- */
|
||||||
|
.dark.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,55%) !important; }
|
||||||
|
.dark.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,30%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#newLeadsGrad)"] { fill: hsl(0,0%,40%) !important; }
|
||||||
|
.light.theme-bw [fill="url(#closedGrad)"] { fill: hsl(0,0%,55%) !important; }
|
||||||
|
|
||||||
|
/* Bar chart: gradient defs stops */
|
||||||
|
.dark.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,55%) !important; }
|
||||||
|
.dark.theme-bw #closedGrad stop { stop-color: hsl(0,0%,30%) !important; }
|
||||||
|
.light.theme-bw #newLeadsGrad stop { stop-color: hsl(0,0%,40%) !important; }
|
||||||
|
.light.theme-bw #closedGrad stop { stop-color: hsl(0,0%,55%) !important; }
|
||||||
|
|
||||||
|
/* ---- feDropShadow flood-color override ---- */
|
||||||
|
.dark.theme-bw feDropShadow { flood-color: hsl(0,0%,50%) !important; flood-opacity: 0.25 !important; }
|
||||||
|
.light.theme-bw feDropShadow { flood-color: hsl(0,0%,45%) !important; flood-opacity: 0.15 !important; }
|
||||||
|
|
||||||
|
/* ---- Hover glow: donut segments + bar rects ---- */
|
||||||
|
.dark.theme-bw [fill*="chart-grad"]:hover,
|
||||||
|
.dark.theme-bw [fill="url(#newLeadsGrad)"]:hover,
|
||||||
|
.dark.theme-bw [fill="url(#closedGrad)"]:hover {
|
||||||
|
filter: drop-shadow(0 0 14px hsla(0,0%,80%,0.45)) brightness(1.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-bw [fill*="chart-grad"]:hover,
|
||||||
|
.light.theme-bw [fill="url(#newLeadsGrad)"]:hover,
|
||||||
|
.light.theme-bw [fill="url(#closedGrad)"]:hover {
|
||||||
|
filter: drop-shadow(0 0 12px hsla(0,0%,10%,0.25)) brightness(1.15) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Avatar PFP Overrides — target SVG data URLs with hardcoded blue (#1d4ed8)
|
||||||
|
Desaturate + brighten the blue fill to light grey
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-bw img[src*="1d4ed8"],
|
||||||
|
.dark.theme-bw img[src*="fill=%231d4ed8"] {
|
||||||
|
filter: saturate(0%) brightness(1.7) !important;
|
||||||
|
}
|
||||||
|
.light.theme-bw img[src*="1d4ed8"],
|
||||||
|
.light.theme-bw img[src*="fill=%231d4ed8"] {
|
||||||
|
filter: saturate(0%) brightness(0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
BW THEME: Force all colored Tailwind utilities to greyscale
|
||||||
|
Targets both regular classes AND dark: variant classes
|
||||||
|
Only applies when .theme-bw is active — no source files touched
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* ----- Dark mode: bg-{color}-{shade} ----- */
|
||||||
|
.dark.theme-bw .bg-blue-500, .dark.theme-bw .dark\:bg-blue-500,
|
||||||
|
.dark.theme-bw .bg-amber-500, .dark.theme-bw .dark\:bg-amber-500,
|
||||||
|
.dark.theme-bw .bg-purple-500, .dark.theme-bw .dark\:bg-purple-500,
|
||||||
|
.dark.theme-bw .bg-emerald-500, .dark.theme-bw .dark\:bg-emerald-500,
|
||||||
|
.dark.theme-bw .bg-red-500, .dark.theme-bw .dark\:bg-red-500,
|
||||||
|
.dark.theme-bw .bg-orange-500, .dark.theme-bw .dark\:bg-orange-500,
|
||||||
|
.dark.theme-bw .bg-yellow-500, .dark.theme-bw .dark\:bg-yellow-500,
|
||||||
|
.dark.theme-bw .bg-violet-500, .dark.theme-bw .dark\:bg-violet-500,
|
||||||
|
.dark.theme-bw .bg-green-500, .dark.theme-bw .dark\:bg-green-500,
|
||||||
|
.dark.theme-bw .bg-sky-500, .dark.theme-bw .dark\:bg-sky-500,
|
||||||
|
.dark.theme-bw .bg-teal-500, .dark.theme-bw .dark\:bg-teal-500,
|
||||||
|
.dark.theme-bw .bg-indigo-500, .dark.theme-bw .dark\:bg-indigo-500,
|
||||||
|
.dark.theme-bw .bg-pink-500, .dark.theme-bw .dark\:bg-pink-500 { background-color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-blue-600, .dark.theme-bw .dark\:bg-blue-600,
|
||||||
|
.dark.theme-bw .bg-amber-600, .dark.theme-bw .dark\:bg-amber-600,
|
||||||
|
.dark.theme-bw .bg-emerald-600, .dark.theme-bw .dark\:bg-emerald-600,
|
||||||
|
.dark.theme-bw .bg-red-600, .dark.theme-bw .dark\:bg-red-600,
|
||||||
|
.dark.theme-bw .bg-green-600, .dark.theme-bw .dark\:bg-green-600,
|
||||||
|
.dark.theme-bw .bg-orange-600, .dark.theme-bw .dark\:bg-orange-600,
|
||||||
|
.dark.theme-bw .bg-yellow-600, .dark.theme-bw .dark\:bg-yellow-600,
|
||||||
|
.dark.theme-bw .bg-violet-600, .dark.theme-bw .dark\:bg-violet-600 { background-color: hsl(0,0%,35%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-blue-400, .dark.theme-bw .dark\:bg-blue-400,
|
||||||
|
.dark.theme-bw .bg-red-400, .dark.theme-bw .dark\:bg-red-400,
|
||||||
|
.dark.theme-bw .bg-emerald-400, .dark.theme-bw .dark\:bg-emerald-400,
|
||||||
|
.dark.theme-bw .bg-amber-400, .dark.theme-bw .dark\:bg-amber-400,
|
||||||
|
.dark.theme-bw .bg-sky-400, .dark.theme-bw .dark\:bg-sky-400,
|
||||||
|
.dark.theme-bw .bg-green-400, .dark.theme-bw .dark\:bg-green-400,
|
||||||
|
.dark.theme-bw .bg-purple-400, .dark.theme-bw .dark\:bg-purple-400,
|
||||||
|
.dark.theme-bw .bg-orange-400, .dark.theme-bw .dark\:bg-orange-400 { background-color: hsl(0,0%,60%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-red-50, .dark.theme-bw .dark\:bg-red-50,
|
||||||
|
.dark.theme-bw .bg-orange-50, .dark.theme-bw .dark\:bg-orange-50,
|
||||||
|
.dark.theme-bw .bg-yellow-50, .dark.theme-bw .dark\:bg-yellow-50,
|
||||||
|
.dark.theme-bw .bg-emerald-50, .dark.theme-bw .dark\:bg-emerald-50,
|
||||||
|
.dark.theme-bw .bg-blue-50, .dark.theme-bw .dark\:bg-blue-50,
|
||||||
|
.dark.theme-bw .bg-amber-50, .dark.theme-bw .dark\:bg-amber-50,
|
||||||
|
.dark.theme-bw .bg-violet-50, .dark.theme-bw .dark\:bg-violet-50,
|
||||||
|
.dark.theme-bw .bg-green-50, .dark.theme-bw .dark\:bg-green-50 { background-color: hsl(0,0%,95%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-red-100, .dark.theme-bw .dark\:bg-red-100,
|
||||||
|
.dark.theme-bw .bg-orange-100, .dark.theme-bw .dark\:bg-orange-100,
|
||||||
|
.dark.theme-bw .bg-yellow-100, .dark.theme-bw .dark\:bg-yellow-100,
|
||||||
|
.dark.theme-bw .bg-emerald-100, .dark.theme-bw .dark\:bg-emerald-100,
|
||||||
|
.dark.theme-bw .bg-blue-100, .dark.theme-bw .dark\:bg-blue-100,
|
||||||
|
.dark.theme-bw .bg-amber-100, .dark.theme-bw .dark\:bg-amber-100,
|
||||||
|
.dark.theme-bw .bg-violet-100, .dark.theme-bw .dark\:bg-violet-100,
|
||||||
|
.dark.theme-bw .bg-green-100, .dark.theme-bw .dark\:bg-green-100 { background-color: hsl(0,0%,88%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-blue-500\/10, .dark.theme-bw .dark\:bg-blue-500\/10,
|
||||||
|
.dark.theme-bw .bg-amber-500\/10, .dark.theme-bw .dark\:bg-amber-500\/10,
|
||||||
|
.dark.theme-bw .bg-purple-500\/10, .dark.theme-bw .dark\:bg-purple-500\/10,
|
||||||
|
.dark.theme-bw .bg-emerald-500\/10, .dark.theme-bw .dark\:bg-emerald-500\/10,
|
||||||
|
.dark.theme-bw .bg-red-500\/10, .dark.theme-bw .dark\:bg-red-500\/10,
|
||||||
|
.dark.theme-bw .bg-orange-500\/10, .dark.theme-bw .dark\:bg-orange-500\/10,
|
||||||
|
.dark.theme-bw .bg-yellow-500\/10, .dark.theme-bw .dark\:bg-yellow-500\/10,
|
||||||
|
.dark.theme-bw .bg-violet-500\/10, .dark.theme-bw .dark\:bg-violet-500\/10,
|
||||||
|
.dark.theme-bw .bg-green-500\/10, .dark.theme-bw .dark\:bg-green-500\/10,
|
||||||
|
.dark.theme-bw .bg-sky-500\/10, .dark.theme-bw .dark\:bg-sky-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-blue-500\/20, .dark.theme-bw .dark\:bg-blue-500\/20,
|
||||||
|
.dark.theme-bw .bg-amber-500\/20, .dark.theme-bw .dark\:bg-amber-500\/20,
|
||||||
|
.dark.theme-bw .bg-purple-500\/20, .dark.theme-bw .dark\:bg-purple-500\/20,
|
||||||
|
.dark.theme-bw .bg-emerald-500\/20, .dark.theme-bw .dark\:bg-emerald-500\/20,
|
||||||
|
.dark.theme-bw .bg-red-500\/20, .dark.theme-bw .dark\:bg-red-500\/20,
|
||||||
|
.dark.theme-bw .bg-violet-500\/20, .dark.theme-bw .dark\:bg-violet-500\/20 { background-color: hsla(0,0%,50%,0.2) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-blue-500\/5, .dark.theme-bw .dark\:bg-blue-500\/5,
|
||||||
|
.dark.theme-bw .bg-amber-500\/5, .dark.theme-bw .dark\:bg-amber-500\/5,
|
||||||
|
.dark.theme-bw .bg-purple-500\/5, .dark.theme-bw .dark\:bg-purple-500\/5,
|
||||||
|
.dark.theme-bw .bg-emerald-500\/5, .dark.theme-bw .dark\:bg-emerald-500\/5,
|
||||||
|
.dark.theme-bw .bg-red-500\/5, .dark.theme-bw .dark\:bg-red-500\/5,
|
||||||
|
.dark.theme-bw .bg-violet-500\/5, .dark.theme-bw .dark\:bg-violet-500\/5 { background-color: hsla(0,0%,50%,0.05) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: text-{color}-{shade} (regular AND dark: variant) ----- */
|
||||||
|
.dark.theme-bw .text-blue-500, .dark.theme-bw .dark\:text-blue-500,
|
||||||
|
.dark.theme-bw .text-amber-500, .dark.theme-bw .dark\:text-amber-500,
|
||||||
|
.dark.theme-bw .text-purple-500, .dark.theme-bw .dark\:text-purple-500,
|
||||||
|
.dark.theme-bw .text-emerald-500, .dark.theme-bw .dark\:text-emerald-500,
|
||||||
|
.dark.theme-bw .text-red-500, .dark.theme-bw .dark\:text-red-500,
|
||||||
|
.dark.theme-bw .text-orange-500, .dark.theme-bw .dark\:text-orange-500,
|
||||||
|
.dark.theme-bw .text-yellow-500, .dark.theme-bw .dark\:text-yellow-500,
|
||||||
|
.dark.theme-bw .text-violet-500, .dark.theme-bw .dark\:text-violet-500,
|
||||||
|
.dark.theme-bw .text-green-500, .dark.theme-bw .dark\:text-green-500,
|
||||||
|
.dark.theme-bw .text-sky-500, .dark.theme-bw .dark\:text-sky-500,
|
||||||
|
.dark.theme-bw .text-teal-500, .dark.theme-bw .dark\:text-teal-500,
|
||||||
|
.dark.theme-bw .text-indigo-500, .dark.theme-bw .dark\:text-indigo-500,
|
||||||
|
.dark.theme-bw .text-pink-500, .dark.theme-bw .dark\:text-pink-500,
|
||||||
|
.dark.theme-bw .text-rose-500, .dark.theme-bw .dark\:text-rose-500 { color: hsl(0,0%,65%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-blue-600, .dark.theme-bw .dark\:text-blue-600,
|
||||||
|
.dark.theme-bw .text-amber-600, .dark.theme-bw .dark\:text-amber-600,
|
||||||
|
.dark.theme-bw .text-emerald-600, .dark.theme-bw .dark\:text-emerald-600,
|
||||||
|
.dark.theme-bw .text-red-600, .dark.theme-bw .dark\:text-red-600,
|
||||||
|
.dark.theme-bw .text-green-600, .dark.theme-bw .dark\:text-green-600,
|
||||||
|
.dark.theme-bw .text-orange-600, .dark.theme-bw .dark\:text-orange-600,
|
||||||
|
.dark.theme-bw .text-yellow-600, .dark.theme-bw .dark\:text-yellow-600,
|
||||||
|
.dark.theme-bw .text-violet-600, .dark.theme-bw .dark\:text-violet-600 { color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-blue-400, .dark.theme-bw .dark\:text-blue-400,
|
||||||
|
.dark.theme-bw .text-red-400, .dark.theme-bw .dark\:text-red-400,
|
||||||
|
.dark.theme-bw .text-emerald-400, .dark.theme-bw .dark\:text-emerald-400,
|
||||||
|
.dark.theme-bw .text-amber-400, .dark.theme-bw .dark\:text-amber-400,
|
||||||
|
.dark.theme-bw .text-sky-400, .dark.theme-bw .dark\:text-sky-400,
|
||||||
|
.dark.theme-bw .text-green-400, .dark.theme-bw .dark\:text-green-400,
|
||||||
|
.dark.theme-bw .text-rose-400, .dark.theme-bw .dark\:text-rose-400,
|
||||||
|
.dark.theme-bw .text-orange-400, .dark.theme-bw .dark\:text-orange-400,
|
||||||
|
.dark.theme-bw .text-purple-400, .dark.theme-bw .dark\:text-purple-400 { color: hsl(0,0%,75%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-blue-700, .dark.theme-bw .dark\:text-blue-700,
|
||||||
|
.dark.theme-bw .text-amber-700, .dark.theme-bw .dark\:text-amber-700,
|
||||||
|
.dark.theme-bw .text-emerald-700, .dark.theme-bw .dark\:text-emerald-700,
|
||||||
|
.dark.theme-bw .text-red-700, .dark.theme-bw .dark\:text-red-700,
|
||||||
|
.dark.theme-bw .text-orange-700, .dark.theme-bw .dark\:text-orange-700,
|
||||||
|
.dark.theme-bw .text-yellow-700, .dark.theme-bw .dark\:text-yellow-700,
|
||||||
|
.dark.theme-bw .text-violet-700, .dark.theme-bw .dark\:text-violet-700 { color: hsl(0,0%,35%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-emerald-600\/60, .dark.theme-bw .dark\:text-emerald-600\/60 { color: hsla(0,0%,50%,0.7) !important; }
|
||||||
|
.dark.theme-bw .text-red-500\/40, .dark.theme-bw .dark\:text-red-500\/40 { color: hsla(0,0%,65%,0.5) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: border-{color}-{shade} ----- */
|
||||||
|
.dark.theme-bw .border-blue-500, .dark.theme-bw .dark\:border-blue-500,
|
||||||
|
.dark.theme-bw .border-amber-500, .dark.theme-bw .dark\:border-amber-500,
|
||||||
|
.dark.theme-bw .border-purple-500, .dark.theme-bw .dark\:border-purple-500,
|
||||||
|
.dark.theme-bw .border-emerald-500, .dark.theme-bw .dark\:border-emerald-500,
|
||||||
|
.dark.theme-bw .border-red-500, .dark.theme-bw .dark\:border-red-500,
|
||||||
|
.dark.theme-bw .border-orange-500, .dark.theme-bw .dark\:border-orange-500,
|
||||||
|
.dark.theme-bw .border-yellow-500, .dark.theme-bw .dark\:border-yellow-500,
|
||||||
|
.dark.theme-bw .border-violet-500, .dark.theme-bw .dark\:border-violet-500 { border-color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .border-blue-500\/20, .dark.theme-bw .dark\:border-blue-500\/20,
|
||||||
|
.dark.theme-bw .border-amber-500\/20, .dark.theme-bw .dark\:border-amber-500\/20,
|
||||||
|
.dark.theme-bw .border-purple-500\/20, .dark.theme-bw .dark\:border-purple-500\/20,
|
||||||
|
.dark.theme-bw .border-emerald-500\/20, .dark.theme-bw .dark\:border-emerald-500\/20,
|
||||||
|
.dark.theme-bw .border-red-500\/20, .dark.theme-bw .dark\:border-red-500\/20 { border-color: hsla(0,0%,50%,0.2) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .border-red-200, .dark.theme-bw .dark\:border-red-200,
|
||||||
|
.dark.theme-bw .border-orange-200, .dark.theme-bw .dark\:border-orange-200,
|
||||||
|
.dark.theme-bw .border-yellow-200, .dark.theme-bw .dark\:border-yellow-200,
|
||||||
|
.dark.theme-bw .border-emerald-200, .dark.theme-bw .dark\:border-emerald-200,
|
||||||
|
.dark.theme-bw .border-blue-200, .dark.theme-bw .dark\:border-blue-200,
|
||||||
|
.dark.theme-bw .border-violet-200, .dark.theme-bw .dark\:border-violet-200 { border-color: hsl(0,0%,80%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .border-red-300, .dark.theme-bw .dark\:border-red-300,
|
||||||
|
.dark.theme-bw .border-orange-300, .dark.theme-bw .dark\:border-orange-300,
|
||||||
|
.dark.theme-bw .border-yellow-300, .dark.theme-bw .dark\:border-yellow-300,
|
||||||
|
.dark.theme-bw .border-emerald-300, .dark.theme-bw .dark\:border-emerald-300,
|
||||||
|
.dark.theme-bw .border-blue-300, .dark.theme-bw .dark\:border-blue-300,
|
||||||
|
.dark.theme-bw .border-violet-300, .dark.theme-bw .dark\:border-violet-300 { border-color: hsl(0,0%,70%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .ring-blue-500\/20, .dark.theme-bw .dark\:ring-blue-500\/20,
|
||||||
|
.dark.theme-bw .ring-amber-500\/20, .dark.theme-bw .dark\:ring-amber-500\/20,
|
||||||
|
.dark.theme-bw .ring-emerald-500\/20, .dark.theme-bw .dark\:ring-emerald-500\/20,
|
||||||
|
.dark.theme-bw .ring-red-500\/20, .dark.theme-bw .dark\:ring-red-500\/20,
|
||||||
|
.dark.theme-bw .ring-purple-500\/20, .dark.theme-bw .dark\:ring-purple-500\/20 { --tw-ring-color: hsla(0,0%,50%,0.2) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: Gradient direction classes ----- */
|
||||||
|
.dark.theme-bw .from-blue-500, .dark.theme-bw .dark\:from-blue-500,
|
||||||
|
.dark.theme-bw .from-amber-500, .dark.theme-bw .dark\:from-amber-500,
|
||||||
|
.dark.theme-bw .from-purple-500, .dark.theme-bw .dark\:from-purple-500,
|
||||||
|
.dark.theme-bw .from-emerald-500, .dark.theme-bw .dark\:from-emerald-500,
|
||||||
|
.dark.theme-bw .from-red-500, .dark.theme-bw .dark\:from-red-500,
|
||||||
|
.dark.theme-bw .from-orange-500, .dark.theme-bw .dark\:from-orange-500,
|
||||||
|
.dark.theme-bw .from-violet-500, .dark.theme-bw .dark\:from-violet-500,
|
||||||
|
.dark.theme-bw .from-green-500, .dark.theme-bw .dark\:from-green-500,
|
||||||
|
.dark.theme-bw .from-pink-500, .dark.theme-bw .dark\:from-pink-500,
|
||||||
|
.dark.theme-bw .from-cyan-400, .dark.theme-bw .dark\:from-cyan-400,
|
||||||
|
.dark.theme-bw .from-fuchsia-500, .dark.theme-bw .dark\:from-fuchsia-500 { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .to-blue-500, .dark.theme-bw .dark\:to-blue-500,
|
||||||
|
.dark.theme-bw .to-amber-500, .dark.theme-bw .dark\:to-amber-500,
|
||||||
|
.dark.theme-bw .to-purple-500, .dark.theme-bw .dark\:to-purple-500,
|
||||||
|
.dark.theme-bw .to-emerald-500, .dark.theme-bw .dark\:to-emerald-500,
|
||||||
|
.dark.theme-bw .to-red-500, .dark.theme-bw .dark\:to-red-500,
|
||||||
|
.dark.theme-bw .to-orange-500, .dark.theme-bw .dark\:to-orange-500,
|
||||||
|
.dark.theme-bw .to-green-500, .dark.theme-bw .dark\:to-green-500,
|
||||||
|
.dark.theme-bw .to-cyan-400, .dark.theme-bw .dark\:to-cyan-400 { --tw-gradient-to: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .from-blue-500\/5, .dark.theme-bw .dark\:from-blue-500\/5,
|
||||||
|
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5,
|
||||||
|
.dark.theme-bw .from-purple-500\/5, .dark.theme-bw .dark\:from-purple-500\/5,
|
||||||
|
.dark.theme-bw .from-emerald-500\/5, .dark.theme-bw .dark\:from-emerald-500\/5,
|
||||||
|
.dark.theme-bw .from-red-500\/5, .dark.theme-bw .dark\:from-red-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .to-violet-500\/\[0\.02\], .dark.theme-bw .dark\:to-violet-500\/\[0\.02\],
|
||||||
|
.dark.theme-bw .to-amber-500\/\[0\.02\], .dark.theme-bw .dark\:to-amber-500\/\[0\.02\] { --tw-gradient-to: hsla(0,0%,50%,0.02) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .from-violet-500\/5, .dark.theme-bw .dark\:from-violet-500\/5,
|
||||||
|
.dark.theme-bw .from-amber-500\/5, .dark.theme-bw .dark\:from-amber-500\/5 { --tw-gradient-from: hsla(0,0%,50%,0.05) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: Inline arbitrary hex colors (stat-card, voice-call, etc) ----- */
|
||||||
|
.dark.theme-bw .text-\[#C84B4B\], .dark.theme-bw .dark\:text-\[\#C84B4B\],
|
||||||
|
.dark.theme-bw .text-\[\#FF1111\], .dark.theme-bw .dark\:text-\[\#FF1111\] { color: hsl(0,0%,60%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-\[#5A8FC4\], .dark.theme-bw .dark\:text-\[\#5A8FC4\],
|
||||||
|
.dark.theme-bw .text-\[\#1144FF\], .dark.theme-bw .dark\:text-\[\#1144FF\] { color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .text-\[#2D3020\], .dark.theme-bw .dark\:text-\[\#2D3020\] { color: hsl(0,0%,20%) !important; }
|
||||||
|
.dark.theme-bw .text-\[#8A9078\], .dark.theme-bw .dark\:text-\[\#8A9078\] { color: hsl(0,0%,55%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .bg-\[#C84B4B\]\/10, .dark.theme-bw .dark\:bg-\[\#C84B4B\]\/10,
|
||||||
|
.dark.theme-bw .bg-\[#5A8FC4\]\/10, .dark.theme-bw .dark\:bg-\[\#5A8FC4\]\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .from-\[#C84B4B\], .dark.theme-bw .dark\:from-\[\#C84B4B\],
|
||||||
|
.dark.theme-bw .via-\[#C84B4B\], .dark.theme-bw .dark\:via-\[\#C84B4B\],
|
||||||
|
.dark.theme-bw .to-\[#C84B4B\], .dark.theme-bw .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-bw .from-\[#5A8FC4\], .dark.theme-bw .dark\:from-\[\#5A8FC4\],
|
||||||
|
.dark.theme-bw .via-\[#5A8FC4\], .dark.theme-bw .dark\:via-\[\#5A8FC4\],
|
||||||
|
.dark.theme-bw .to-\[#5A8FC4\], .dark.theme-bw .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: SVG stroke/fill (app-shell, etc) ----- */
|
||||||
|
.dark.theme-bw [stroke="#CC0000"] { stroke: hsl(0,0%,50%) !important; }
|
||||||
|
.dark.theme-bw [stroke="#0033CC"] { stroke: hsl(0,0%,50%) !important; }
|
||||||
|
.dark.theme-bw [fill="#2D3020"] { fill: hsl(0,0%,20%) !important; }
|
||||||
|
.dark.theme-bw [fill="#8A9078"] { fill: hsl(0,0%,55%) !important; }
|
||||||
|
|
||||||
|
/* ----- Dark mode: Hover states ----- */
|
||||||
|
.dark.theme-bw .hover\:bg-sky-400\/20:hover { background-color: hsla(0,0%,50%,0.2) !important; }
|
||||||
|
.dark.theme-bw .hover\:bg-emerald-700:hover { background-color: hsl(0,0%,30%) !important; }
|
||||||
|
|
||||||
|
/* ----- Light mode overrides ----- */
|
||||||
|
.light.theme-bw .bg-blue-500, .light.theme-bw .dark\:bg-blue-500,
|
||||||
|
.light.theme-bw .bg-amber-500, .light.theme-bw .dark\:bg-amber-500,
|
||||||
|
.light.theme-bw .bg-purple-500, .light.theme-bw .dark\:bg-purple-500,
|
||||||
|
.light.theme-bw .bg-emerald-500, .light.theme-bw .dark\:bg-emerald-500,
|
||||||
|
.light.theme-bw .bg-red-500, .light.theme-bw .dark\:bg-red-500,
|
||||||
|
.light.theme-bw .bg-orange-500, .light.theme-bw .dark\:bg-orange-500,
|
||||||
|
.light.theme-bw .bg-yellow-500, .light.theme-bw .dark\:bg-yellow-500,
|
||||||
|
.light.theme-bw .bg-violet-500, .light.theme-bw .dark\:bg-violet-500,
|
||||||
|
.light.theme-bw .bg-green-500, .light.theme-bw .dark\:bg-green-500,
|
||||||
|
.light.theme-bw .bg-sky-500, .light.theme-bw .dark\:bg-sky-500 { background-color: hsl(0,0%,50%) !important; }
|
||||||
|
|
||||||
|
.light.theme-bw .text-blue-500, .light.theme-bw .dark\:text-blue-500,
|
||||||
|
.light.theme-bw .text-amber-500, .light.theme-bw .dark\:text-amber-500,
|
||||||
|
.light.theme-bw .text-purple-500, .light.theme-bw .dark\:text-purple-500,
|
||||||
|
.light.theme-bw .text-emerald-500, .light.theme-bw .dark\:text-emerald-500,
|
||||||
|
.light.theme-bw .text-red-500, .light.theme-bw .dark\:text-red-500,
|
||||||
|
.light.theme-bw .text-orange-500, .light.theme-bw .dark\:text-orange-500,
|
||||||
|
.light.theme-bw .text-yellow-500, .light.theme-bw .dark\:text-yellow-500,
|
||||||
|
.light.theme-bw .text-violet-500, .light.theme-bw .dark\:text-violet-500,
|
||||||
|
.light.theme-bw .text-sky-400, .light.theme-bw .dark\:text-sky-400,
|
||||||
|
.light.theme-bw .text-rose-500, .light.theme-bw .dark\:text-rose-500,
|
||||||
|
.light.theme-bw .text-green-500, .light.theme-bw .dark\:text-green-500 { color: hsl(0,0%,40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-bw .bg-blue-500\/10, .light.theme-bw .dark\:bg-blue-500\/10,
|
||||||
|
.light.theme-bw .bg-amber-500\/10, .light.theme-bw .dark\:bg-amber-500\/10,
|
||||||
|
.light.theme-bw .bg-purple-500\/10, .light.theme-bw .dark\:bg-purple-500\/10,
|
||||||
|
.light.theme-bw .bg-emerald-500\/10, .light.theme-bw .dark\:bg-emerald-500\/10,
|
||||||
|
.light.theme-bw .bg-red-500\/10, .light.theme-bw .dark\:bg-red-500\/10 { background-color: hsla(0,0%,50%,0.1) !important; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Pipeline (Kanban Board + Stat Cards) — white glow instead of stage colours
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* ---- Kanban columns ---- */
|
||||||
|
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border {
|
||||||
|
box-shadow: 0 0 20px hsla(0,0%,100%,0.06), 0 0 50px hsla(0,0%,100%,0.02) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .rounded-t-lg {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom-color: hsla(0,0%,100%,0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .overflow-x-auto > .flex-col.rounded-lg.border .h-2.w-2.rounded-full {
|
||||||
|
background: hsl(0,0%,65%) !important;
|
||||||
|
box-shadow: 0 0 6px hsla(0,0%,100%,0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .overflow-x-auto .cursor-grab .rounded-full {
|
||||||
|
background: hsl(0,0%,50%) !important;
|
||||||
|
box-shadow: 0 0 6px hsla(0,0%,100%,0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dashboard cards (Pipeline Overview stat cards, Analytics charts, Recent Leads table) ---- */
|
||||||
|
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card {
|
||||||
|
box-shadow: 0 0 16px hsla(0,0%,100%,0.05), 0 0 40px hsla(0,0%,100%,0.015) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl {
|
||||||
|
background: transparent !important;
|
||||||
|
box-shadow: 0 0 12px hsla(0,0%,100%,0.15), inset 0 0 8px hsla(0,0%,100%,0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .rounded-xl svg {
|
||||||
|
color: hsl(0,0%,70%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-bw .z-\[1\].space-y-6 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||||
|
background: hsl(0,0%,50%) !important;
|
||||||
|
box-shadow: 0 0 8px hsla(0,0%,100%,0.3) !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"name": "Black & White",
|
||||||
|
"description": "Clean monochrome theme with pure grayscale tones and subtle texture",
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "0 0% 4%",
|
||||||
|
"foreground": "0 0% 95%",
|
||||||
|
"primary": "0 0% 95%",
|
||||||
|
"secondary": "0 0% 50%",
|
||||||
|
"accent": "0 0% 95%",
|
||||||
|
"muted": "0 0% 12%",
|
||||||
|
"border": "0 0% 20%",
|
||||||
|
"ring": "0 0% 70%",
|
||||||
|
"card": "0 0% 8%",
|
||||||
|
"popover": "0 0% 8%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(0,0%,90%), hsl(0,0%,60%))",
|
||||||
|
"link": "hsl(0, 0%, 80%)",
|
||||||
|
"link-hover": "hsl(0, 0%, 100%)",
|
||||||
|
"code": "hsl(0, 0%, 80%)",
|
||||||
|
"blockquote": "hsl(0, 0%, 60%)",
|
||||||
|
"table-header": "hsl(0, 0%, 80%)",
|
||||||
|
"stat-card-value": "hsl(0, 0%, 95%)",
|
||||||
|
"badge": "hsl(0, 0%, 80%)",
|
||||||
|
"page-header-title": "hsl(0, 0%, 95%)",
|
||||||
|
"text-glow": "0 0 10px hsla(0,0%,100%,0.15), 0 0 30px hsla(0,0%,100%,0.05)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "Deep black with subtle silver radial gradients and faint dots",
|
||||||
|
"glow-colors": ["hsla(0,0%,100%,0.04)", "hsla(0,0%,100%,0.02)"]
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"body-background-animation": "bw-drift 40s ease-in-out infinite alternate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "0 0% 97%",
|
||||||
|
"foreground": "0 0% 10%",
|
||||||
|
"primary": "0 0% 10%",
|
||||||
|
"secondary": "0 0% 40%",
|
||||||
|
"accent": "0 0% 10%",
|
||||||
|
"muted": "0 0% 90%",
|
||||||
|
"border": "0 0% 82%",
|
||||||
|
"ring": "0 0% 50%",
|
||||||
|
"card": "0 0% 100%",
|
||||||
|
"popover": "0 0% 100%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(0,0%,20%), hsl(0,0%,50%))",
|
||||||
|
"link": "hsl(0, 0%, 30%)",
|
||||||
|
"link-hover": "hsl(0, 0%, 0%)",
|
||||||
|
"code": "hsl(0, 0%, 30%)",
|
||||||
|
"blockquote": "hsl(0, 0%, 50%)",
|
||||||
|
"table-header": "hsl(0, 0%, 30%)",
|
||||||
|
"stat-card-value": "hsl(0, 0%, 10%)",
|
||||||
|
"badge": "hsl(0, 0%, 30%)",
|
||||||
|
"page-header-title": "hsl(0, 0%, 10%)",
|
||||||
|
"text-glow": "0 0 8px hsla(0,0%,0%,0.08), 0 0 20px hsla(0,0%,0%,0.04)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "White with subtle silver-gray radial gradients and faint grid texture",
|
||||||
|
"glow-colors": ["hsla(0,0%,0%,0.02)", "hsla(0,0%,0%,0.01)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
.dark.theme-cosmic {
|
||||||
|
--background: 260 30% 8%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
--card: 260 25% 12%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
--popover: 260 25% 12%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
--primary: 280 60% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 260 20% 20%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
--muted: 260 20% 18%;
|
||||||
|
--muted-foreground: 260 10% 60%;
|
||||||
|
--accent: 280 60% 55%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 260 20% 22%;
|
||||||
|
--input: 260 20% 22%;
|
||||||
|
--ring: 280 60% 55%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 260 30% 8%;
|
||||||
|
--sidebar-foreground: 210 40% 98%;
|
||||||
|
--sidebar-primary: 280 60% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 280 60% 55%;
|
||||||
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
|
--sidebar-border: 260 20% 20%;
|
||||||
|
--sidebar-ring: 280 60% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic {
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 260 30% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 260 30% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 260 30% 12%;
|
||||||
|
--primary: 280 50% 50%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 260 20% 92%;
|
||||||
|
--secondary-foreground: 260 30% 20%;
|
||||||
|
--muted: 260 15% 92%;
|
||||||
|
--muted-foreground: 260 10% 45%;
|
||||||
|
--accent: 280 50% 50%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 80% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 260 15% 85%;
|
||||||
|
--input: 260 15% 85%;
|
||||||
|
--ring: 280 50% 50%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 260 30% 12%;
|
||||||
|
--sidebar-primary: 280 50% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 280 50% 50%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
|
--sidebar-border: 260 15% 85%;
|
||||||
|
--sidebar-ring: 280 50% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic body {
|
||||||
|
background: transparent
|
||||||
|
radial-gradient(ellipse 120% 70% at 20% 30%, rgba(100, 60, 200, 0.25) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 60% at 80% 70%, rgba(0, 200, 100, 0.15) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 60% 20%, rgba(200, 50, 180, 0.2) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse 100% 80% at 40% 80%, rgba(50, 100, 220, 0.15) 0%, transparent 50%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: cosmic-drift 90s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic body > div {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 15% 25%, rgba(0, 200, 80, 0.06) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 70% 50% at 80% 70%, rgba(100, 60, 200, 0.04) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse 50% 40% at 60% 20%, rgba(0, 200, 80, 0.05) 0%, transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cosmic-drift {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
50% { background-position: 100% 100%; }
|
||||||
|
100% { background-position: 0% 0%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .text-glow {
|
||||||
|
text-shadow: 0 0 10px hsl(120, 100%, 45%), 0 0 30px hsl(120, 100%, 45%), 0 0 60px rgba(0, 220, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .text-glow {
|
||||||
|
text-shadow: 0 0 8px hsl(280, 50%, 50%), 0 0 20px hsl(280, 50%, 50%), 0 0 40px rgba(200, 50, 200, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic h1, .dark.theme-cosmic h2, .dark.theme-cosmic h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(120, 100%, 45%), hsl(170, 80%, 50%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic h1, .light.theme-cosmic h2, .light.theme-cosmic h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(280, 50%, 45%), hsl(280, 60%, 55%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic a {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic a {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic a:hover {
|
||||||
|
color: hsl(170, 80%, 50%);
|
||||||
|
text-shadow: 0 0 8px rgba(0, 220, 100, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic a:hover {
|
||||||
|
color: hsl(280, 60%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic blockquote {
|
||||||
|
border-left: 3px solid hsl(280, 60%, 55%);
|
||||||
|
color: hsl(260, 15%, 70%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic blockquote {
|
||||||
|
border-left: 3px solid hsl(280, 50%, 50%);
|
||||||
|
color: hsl(260, 10%, 45%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic code {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
background: rgba(100, 60, 200, 0.15);
|
||||||
|
border: 1px solid rgba(100, 60, 200, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic code {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
background: rgba(100, 60, 200, 0.06);
|
||||||
|
border: 1px solid rgba(100, 60, 200, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic pre {
|
||||||
|
background: rgba(13, 8, 32, 0.8);
|
||||||
|
border: 1px solid rgba(100, 60, 200, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic pre {
|
||||||
|
background: rgba(0, 200, 80, 0.03);
|
||||||
|
border: 1px solid rgba(0, 200, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic hr {
|
||||||
|
border-color: rgba(100, 60, 200, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic hr {
|
||||||
|
border-color: rgba(0, 200, 80, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic table th {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic table th {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic table td {
|
||||||
|
border-color: rgba(100, 60, 200, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic table td {
|
||||||
|
border-color: rgba(0, 200, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic ::selection {
|
||||||
|
background: rgba(200, 50, 200, 0.25);
|
||||||
|
color: hsl(0, 0%, 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic ::selection {
|
||||||
|
background: rgba(200, 50, 200, 0.15);
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic input::placeholder,
|
||||||
|
.dark.theme-cosmic textarea::placeholder {
|
||||||
|
color: hsl(260, 10%, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic input::placeholder,
|
||||||
|
.light.theme-cosmic textarea::placeholder {
|
||||||
|
color: hsl(260, 10%, 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .page-header-title {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .page-header-title {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .stat-card-value {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .stat-card-value {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .badge {
|
||||||
|
color: hsl(120, 90%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .badge {
|
||||||
|
color: hsl(280, 50%, 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(280, 70%, 65%), hsl(280, 60%, 55%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(120, 100%, 45%), hsl(120, 100%, 55%), hsl(120, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(280, 60%, 55%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(120, 100%, 45%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(280, 60%, 55%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(120, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(280, 60%, 55%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(120, 100%, 45%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||||
|
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(120, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 50%, 50%), hsl(280, 60%, 60%), hsl(280, 50%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(120, 80%, 40%), hsl(120, 80%, 50%), hsl(120, 80%, 40%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .growth-word,
|
||||||
|
.light.theme-cosmic .growth-word {
|
||||||
|
-webkit-text-fill-color: #1BB0CE !important;
|
||||||
|
color: #1BB0CE !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cosmic colors
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* ---- SVG gradient overrides (donut chart segments) ---- */
|
||||||
|
.dark.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 80%, 60%) !important; }
|
||||||
|
.dark.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 80%, 50%) !important; }
|
||||||
|
.dark.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 15%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic #chart-grad-0 stop { stop-color: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic #chart-grad-1 stop { stop-color: hsl(120, 80%, 40%) !important; }
|
||||||
|
.light.theme-cosmic #chart-grad-2 stop { stop-color: hsl(320, 70%, 55%) !important; }
|
||||||
|
.light.theme-cosmic #chart-grad-3 stop { stop-color: hsl(170, 70%, 40%) !important; }
|
||||||
|
.light.theme-cosmic #chart-grad-4 stop { stop-color: hsl(260, 10%, 55%) !important; }
|
||||||
|
|
||||||
|
/* ---- SVG gradient overrides (bar chart) ---- */
|
||||||
|
.dark.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic #closedGrad stop { stop-color: hsl(120, 100%, 45%) !important; }
|
||||||
|
.light.theme-cosmic #newLeadsGrad stop { stop-color: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic #closedGrad stop { stop-color: hsl(120, 80%, 40%) !important; }
|
||||||
|
|
||||||
|
/* ---- Inline chart colors (legend dots, hover text) ---- */
|
||||||
|
.dark.theme-cosmic [style*="background: #3b82f6"],
|
||||||
|
.dark.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="background: #f59e0b"],
|
||||||
|
.dark.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="background: #8b5cf6"],
|
||||||
|
.dark.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 80%, 60%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="background: #10b981"],
|
||||||
|
.dark.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 80%, 50%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="background: #6B7280"],
|
||||||
|
.dark.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 15%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic [style*="background: #3b82f6"],
|
||||||
|
.light.theme-cosmic [style*="background:#3b82f6"] { background: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic [style*="background: #f59e0b"],
|
||||||
|
.light.theme-cosmic [style*="background:#f59e0b"] { background: hsl(120, 80%, 40%) !important; }
|
||||||
|
.light.theme-cosmic [style*="background: #8b5cf6"],
|
||||||
|
.light.theme-cosmic [style*="background:#8b5cf6"] { background: hsl(320, 70%, 55%) !important; }
|
||||||
|
.light.theme-cosmic [style*="background: #10b981"],
|
||||||
|
.light.theme-cosmic [style*="background:#10b981"] { background: hsl(170, 70%, 40%) !important; }
|
||||||
|
.light.theme-cosmic [style*="background: #6B7280"],
|
||||||
|
.light.theme-cosmic [style*="background:#6B7280"] { background: hsl(260, 10%, 55%) !important; }
|
||||||
|
|
||||||
|
/* ---- Chart legend hover borders/box-shadows ---- */
|
||||||
|
.dark.theme-cosmic [style*="border: 1px solid #3b82f6"],
|
||||||
|
.dark.theme-cosmic [style*="border:1px solid #3b82f6"] { border-color: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="border: 1px solid #f59e0b"],
|
||||||
|
.dark.theme-cosmic [style*="border:1px solid #f59e0b"] { border-color: hsl(120, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="border: 1px solid #8b5cf6"],
|
||||||
|
.dark.theme-cosmic [style*="border:1px solid #8b5cf6"] { border-color: hsl(320, 80%, 60%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="border: 1px solid #10b981"],
|
||||||
|
.dark.theme-cosmic [style*="border:1px solid #10b981"] { border-color: hsl(170, 80%, 50%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="border: 1px solid #6B7280"],
|
||||||
|
.dark.theme-cosmic [style*="border:1px solid #6B7280"] { border-color: hsl(260, 15%, 40%) !important; }
|
||||||
|
|
||||||
|
/* ---- Donut chart segment text color on hover ---- */
|
||||||
|
.dark.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 80%, 60%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 80%, 50%) !important; }
|
||||||
|
.dark.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 15%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic [style*="color: #3b82f6"] { color: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic [style*="color: #f59e0b"] { color: hsl(120, 80%, 40%) !important; }
|
||||||
|
.light.theme-cosmic [style*="color: #8b5cf6"] { color: hsl(320, 70%, 55%) !important; }
|
||||||
|
.light.theme-cosmic [style*="color: #10b981"] { color: hsl(170, 70%, 40%) !important; }
|
||||||
|
.light.theme-cosmic [style*="color: #6B7280"] { color: hsl(260, 10%, 55%) !important; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Stat Cards — Icon backgrounds, text colors, progress bars
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(280, 60%, 55%, 0.12) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(280, 60%, 55%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 60%, 55%), hsl(120, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(280, 60%, 55%, 0.2), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(280, 50%, 50%, 0.1) !important;
|
||||||
|
}
|
||||||
|
.light.theme-cosmic .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(280, 50%, 50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 50%, 50%), hsl(120, 80%, 40%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(280, 50%, 50%, 0.15), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Inline hex color overrides — stat card values, page icons, etc.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-cosmic .text-\[#C84B4B\], .dark.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic .text-\[#5A8FC4\], .dark.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cosmic .text-\[#FF1111\], .dark.theme-cosmic .dark\:text-\[\#FF1111\] { color: hsl(280, 80%, 60%) !important; }
|
||||||
|
.dark.theme-cosmic .text-\[#1144FF\], .dark.theme-cosmic .dark\:text-\[\#1144FF\] { color: hsl(120, 100%, 55%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic .text-\[#C84B4B\], .light.theme-cosmic .dark\:text-\[\#C84B4B\] { color: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic .text-\[#5A8FC4\], .light.theme-cosmic .dark\:text-\[\#5A8FC4\] { color: hsl(120, 80%, 40%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cosmic .bg-\[#C84B4B\]\/10, .dark.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 60%, 55%, 0.1) !important; }
|
||||||
|
.dark.theme-cosmic .bg-\[#5A8FC4\]\/10, .dark.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 100%, 45%, 0.1) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic .bg-\[#C84B4B\]\/10, .light.theme-cosmic .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(280, 50%, 50%, 0.08) !important; }
|
||||||
|
.light.theme-cosmic .bg-\[#5A8FC4\]\/10, .light.theme-cosmic .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(120, 80%, 40%, 0.08) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cosmic .from-\[#C84B4B\], .dark.theme-cosmic .dark\:from-\[\#C84B4B\],
|
||||||
|
.dark.theme-cosmic .via-\[#C84B4B\], .dark.theme-cosmic .dark\:via-\[\#C84B4B\],
|
||||||
|
.dark.theme-cosmic .to-\[#C84B4B\], .dark.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 60%, 55%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cosmic .from-\[#5A8FC4\], .dark.theme-cosmic .dark\:from-\[\#5A8FC4\],
|
||||||
|
.dark.theme-cosmic .via-\[#5A8FC4\], .dark.theme-cosmic .dark\:via-\[\#5A8FC4\],
|
||||||
|
.dark.theme-cosmic .to-\[#5A8FC4\], .dark.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 100%, 45%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic .from-\[#C84B4B\], .light.theme-cosmic .dark\:from-\[\#C84B4B\],
|
||||||
|
.light.theme-cosmic .via-\[#C84B4B\], .light.theme-cosmic .dark\:via-\[\#C84B4B\],
|
||||||
|
.light.theme-cosmic .to-\[#C84B4B\], .light.theme-cosmic .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(280, 50%, 50%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cosmic .from-\[#5A8FC4\], .light.theme-cosmic .dark\:from-\[\#5A8FC4\],
|
||||||
|
.light.theme-cosmic .via-\[#5A8FC4\], .light.theme-cosmic .dark\:via-\[\#5A8FC4\],
|
||||||
|
.light.theme-cosmic .to-\[#5A8FC4\], .light.theme-cosmic .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(120, 80%, 40%) !important; }
|
||||||
|
|
||||||
|
/* ---- SVG stroke/fill overrides ---- */
|
||||||
|
.dark.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 60%, 55%) !important; }
|
||||||
|
.dark.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 100%, 45%) !important; }
|
||||||
|
.light.theme-cosmic [stroke="#CC0000"] { stroke: hsl(280, 50%, 50%) !important; }
|
||||||
|
.light.theme-cosmic [stroke="#0033CC"] { stroke: hsl(120, 80%, 40%) !important; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Light mode — deeper visual polish
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.light.theme-cosmic .rounded-xl.border.bg-card {
|
||||||
|
box-shadow: 0 1px 3px hsla(280, 50%, 30%, 0.08), 0 4px 12px hsla(280, 50%, 30%, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic aside .group:hover {
|
||||||
|
background: hsla(280, 50%, 50%, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cosmic .hover\:bg-accent:hover {
|
||||||
|
background: hsla(280, 50%, 50%, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Client Portal Overrides — Background for portal pages
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Make the outer portal container transparent so body::before background shows */
|
||||||
|
.dark.theme-cosmic .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar — dark glass */
|
||||||
|
.dark.theme-cosmic aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-right-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar nav items — muted text */
|
||||||
|
.dark.theme-cosmic aside .text-muted-foreground {
|
||||||
|
color: hsl(240, 8%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cosmic aside button:hover {
|
||||||
|
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||||
|
color: hsl(0, 0%, 90%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar active nav item */
|
||||||
|
.dark.theme-cosmic aside .bg-primary\/10.text-primary {
|
||||||
|
background: hsl(330, 100%, 50%) !important;
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
|
||||||
|
border-color: hsl(330, 100%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cosmic aside .bg-primary\/10.text-primary svg {
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal header — dark glass */
|
||||||
|
.dark.theme-cosmic header.bg-card\/80 {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal card backgrounds — semi-transparent glass */
|
||||||
|
.dark.theme-cosmic .bg-card {
|
||||||
|
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal borders */
|
||||||
|
.dark.theme-cosmic .border-border {
|
||||||
|
border-color: hsl(270, 25%, 12%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal input fields */
|
||||||
|
.dark.theme-cosmic .bg-muted {
|
||||||
|
background: hsl(240, 12%, 9%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal primary buttons */
|
||||||
|
.dark.theme-cosmic .bg-primary {
|
||||||
|
background: hsl(330, 100%, 50%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
|
||||||
|
}
|
||||||
|
.dark.theme-cosmic .bg-primary:hover {
|
||||||
|
background: hsl(330, 100%, 55%) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"id": "cosmic",
|
||||||
|
"name": "Cosmic",
|
||||||
|
"description": "Dark mode: green text with purple highlights + glowing NEBULA header; Light mode: purple text + purple NEBULA header",
|
||||||
|
"type": "dual",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(260, 30%, 8%)",
|
||||||
|
"foreground": "hsl(210, 40%, 98%)",
|
||||||
|
"card": "hsl(260, 25%, 12%)",
|
||||||
|
"cardForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"popover": "hsl(260, 25%, 12%)",
|
||||||
|
"popoverForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"primary": "hsl(280, 60%, 55%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(260, 20%, 20%)",
|
||||||
|
"secondaryForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"muted": "hsl(260, 20%, 18%)",
|
||||||
|
"mutedForeground": "hsl(260, 10%, 60%)",
|
||||||
|
"accent": "hsl(280, 60%, 55%)",
|
||||||
|
"accentForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"destructive": "hsl(0, 70%, 50%)",
|
||||||
|
"destructiveForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"border": "hsl(260, 20%, 22%)",
|
||||||
|
"input": "hsl(260, 20%, 22%)",
|
||||||
|
"sidebar": "hsl(260, 30%, 8%)",
|
||||||
|
"sidebarForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"sidebarPrimary": "hsl(280, 60%, 55%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(280, 60%, 55%)",
|
||||||
|
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"sidebarBorder": "hsl(260, 20%, 22%)",
|
||||||
|
"sidebarRing": "hsl(280, 60%, 55%)"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, #00E060, #2dd4bf)",
|
||||||
|
"body": "#e8e8ef",
|
||||||
|
"muted": "#8a7faa",
|
||||||
|
"link": "#00E060",
|
||||||
|
"linkHover": "#2dd4bf",
|
||||||
|
"code": "#00E060",
|
||||||
|
"placeholder": "#554b70"
|
||||||
|
},
|
||||||
|
"backgroundImage": null,
|
||||||
|
"backgroundGradients": [
|
||||||
|
"rgba(100, 60, 200, 0.15) at 20% 30%",
|
||||||
|
"rgba(0, 220, 100, 0.08) at 80% 70%",
|
||||||
|
"rgba(200, 50, 180, 0.10) at 60% 20%",
|
||||||
|
"rgba(50, 100, 220, 0.08) at 40% 80%"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(0, 0%, 98%)",
|
||||||
|
"foreground": "hsl(260, 30%, 12%)",
|
||||||
|
"card": "hsl(0, 0%, 100%)",
|
||||||
|
"cardForeground": "hsl(260, 30%, 12%)",
|
||||||
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
|
"popoverForeground": "hsl(260, 30%, 12%)",
|
||||||
|
"primary": "hsl(280, 50%, 50%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(260, 20%, 92%)",
|
||||||
|
"secondaryForeground": "hsl(260, 30%, 20%)",
|
||||||
|
"muted": "hsl(260, 15%, 92%)",
|
||||||
|
"mutedForeground": "hsl(260, 10%, 45%)",
|
||||||
|
"accent": "hsl(280, 50%, 50%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"destructive": "hsl(0, 80%, 50%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(260, 15%, 85%)",
|
||||||
|
"input": "hsl(260, 15%, 85%)",
|
||||||
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarForeground": "hsl(260, 30%, 12%)",
|
||||||
|
"sidebarPrimary": "hsl(280, 50%, 50%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(280, 50%, 50%)",
|
||||||
|
"sidebarAccentForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarBorder": "hsl(260, 15%, 85%)",
|
||||||
|
"sidebarRing": "hsl(280, 50%, 50%)"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, #7c3aed, #a855f7)",
|
||||||
|
"body": "#1a0a2e",
|
||||||
|
"muted": "#6b5b7a",
|
||||||
|
"link": "#7c3aed",
|
||||||
|
"linkHover": "#a855f7",
|
||||||
|
"code": "#7c3aed",
|
||||||
|
"placeholder": "#a8a0b0"
|
||||||
|
},
|
||||||
|
"backgroundImage": null,
|
||||||
|
"backgroundGradients": [
|
||||||
|
"rgba(0, 200, 80, 0.06) at 15% 25%",
|
||||||
|
"rgba(100, 60, 200, 0.04) at 80% 70%",
|
||||||
|
"rgba(0, 200, 80, 0.05) at 60% 20%"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"borderRadius": "0.5rem",
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, sans-serif",
|
||||||
|
"headingFont": "Inter, sans-serif"
|
||||||
|
},
|
||||||
|
"keyColors": {
|
||||||
|
"primary": "#a855f7 (dark) / #7c3aed (light)",
|
||||||
|
"background": "#0d0820 (dark) / #fafafa (light)",
|
||||||
|
"card": "#16102e (dark) / #ffffff (light)",
|
||||||
|
"sidebar": "#0d0820 (dark) / #ffffff (light)",
|
||||||
|
"border": "#2a2240 (dark) / #d4d0dc (light)",
|
||||||
|
"text": "#e8e8ef (dark) / #1a0a2e (light)",
|
||||||
|
"mutedText": "#8a7faa (dark) / #6b5b7a (light)",
|
||||||
|
"headingGradient": "#00E060 → #2dd4bf (dark) / #7c3aed → #a855f7 (light)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
Cuba Theme — Classic Cuban car on a playa at golden hour (6pm sunset)
|
||||||
|
Malecón, Havana — warm sunset tones, turquoise Caribbean accents
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-cuba {
|
||||||
|
--background: 25 40% 6%;
|
||||||
|
--foreground: 30 20% 90%;
|
||||||
|
--card: 25 35% 10%;
|
||||||
|
--card-foreground: 30 20% 90%;
|
||||||
|
--popover: 25 35% 10%;
|
||||||
|
--popover-foreground: 30 20% 90%;
|
||||||
|
--primary: 15 85% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 185 60% 50%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 25 20% 15%;
|
||||||
|
--muted-foreground: 25 10% 55%;
|
||||||
|
--accent: 195 65% 55%;
|
||||||
|
--accent-foreground: 0 0% 0%;
|
||||||
|
--destructive: 0 75% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--success: 145 55% 45%;
|
||||||
|
--success-foreground: 0 0% 100%;
|
||||||
|
--warning: 35 90% 55%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--border: 25 25% 18%;
|
||||||
|
--input: 25 20% 15%;
|
||||||
|
--ring: 15 85% 55%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--sidebar: 25 40% 6%;
|
||||||
|
--sidebar-foreground: 30 20% 90%;
|
||||||
|
--sidebar-primary: 15 85% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 25 30% 12%;
|
||||||
|
--sidebar-accent-foreground: 30 20% 90%;
|
||||||
|
--sidebar-border: 25 25% 15%;
|
||||||
|
--sidebar-ring: 15 85% 55%;
|
||||||
|
--chart-1: 15 85% 55%;
|
||||||
|
--chart-2: 185 60% 50%;
|
||||||
|
--chart-3: 45 85% 55%;
|
||||||
|
--chart-4: 195 65% 55%;
|
||||||
|
--chart-5: 335 60% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cuba {
|
||||||
|
--background: 30 30% 96%;
|
||||||
|
--foreground: 25 40% 15%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 25 40% 15%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 25 40% 15%;
|
||||||
|
--primary: 15 80% 50%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 185 55% 45%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 30 15% 90%;
|
||||||
|
--muted-foreground: 25 10% 45%;
|
||||||
|
--accent: 195 60% 50%;
|
||||||
|
--accent-foreground: 0 0% 0%;
|
||||||
|
--destructive: 0 72% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--success: 145 50% 40%;
|
||||||
|
--success-foreground: 0 0% 100%;
|
||||||
|
--warning: 35 85% 50%;
|
||||||
|
--warning-foreground: 0 0% 0%;
|
||||||
|
--border: 30 15% 85%;
|
||||||
|
--input: 30 10% 88%;
|
||||||
|
--ring: 15 80% 50%;
|
||||||
|
--radius: 0.75rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 25 40% 15%;
|
||||||
|
--sidebar-primary: 15 80% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 30 15% 92%;
|
||||||
|
--sidebar-accent-foreground: 25 40% 15%;
|
||||||
|
--sidebar-border: 30 15% 85%;
|
||||||
|
--sidebar-ring: 15 80% 50%;
|
||||||
|
--chart-1: 15 80% 50%;
|
||||||
|
--chart-2: 185 55% 45%;
|
||||||
|
--chart-3: 45 80% 50%;
|
||||||
|
--chart-4: 195 60% 50%;
|
||||||
|
--chart-5: 335 55% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Malecón, Havana at golden hour background ──────────────────── */
|
||||||
|
.dark.theme-cuba body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: url("https://images.unsplash.com/photo-1584098179114-7962ad5d6653?w=1920&q=80") no-repeat center center fixed;
|
||||||
|
background-size: cover;
|
||||||
|
background-attachment: fixed;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cuba body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: hsl(30, 30%, 96%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Make main containers transparent so body::before shows ──────── */
|
||||||
|
.dark.theme-cuba .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cuba .min-h-screen.bg-\[\#F8F8F8\],
|
||||||
|
.dark.theme-cuba .dark\:bg-\[\#0A0A0A\] {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cuba .min-h-screen {
|
||||||
|
background: hsl(30, 30%, 96%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Frosted glass cards ────────────────────────────────────────── */
|
||||||
|
.dark.theme-cuba .bg-card,
|
||||||
|
.dark.theme-cuba .bg-popover,
|
||||||
|
.dark.theme-cuba .bg-muted {
|
||||||
|
background-color: hsl(var(--card) / 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cuba .bg-sidebar {
|
||||||
|
background-color: hsl(var(--sidebar) / 0.9);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cuba .bg-card,
|
||||||
|
.light.theme-cuba .bg-popover,
|
||||||
|
.light.theme-cuba .bg-muted {
|
||||||
|
background-color: hsl(var(--card) / 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cuba .bg-sidebar {
|
||||||
|
background-color: hsl(var(--sidebar) / 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-transparency: reduce) {
|
||||||
|
.theme-cuba .bg-card,
|
||||||
|
.theme-cuba .bg-popover,
|
||||||
|
.theme-cuba .bg-muted,
|
||||||
|
.theme-cuba .bg-sidebar {
|
||||||
|
backdrop-filter: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
{
|
||||||
|
"id": "cuba",
|
||||||
|
"name": "Cuba Theme",
|
||||||
|
"description": "Classic Cuban car on a playa at golden hour — warm sunset tones with turquoise Caribbean accents.",
|
||||||
|
"type": "dual",
|
||||||
|
"default": false,
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(25, 40%, 6%)",
|
||||||
|
"foreground": "hsl(30, 20%, 90%)",
|
||||||
|
"card": "hsl(25, 35%, 10%)",
|
||||||
|
"cardForeground": "hsl(30, 20%, 90%)",
|
||||||
|
"popover": "hsl(25, 35%, 10%)",
|
||||||
|
"popoverForeground": "hsl(30, 20%, 90%)",
|
||||||
|
"primary": "hsl(15, 85%, 55%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(185, 60%, 50%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"muted": "hsl(25, 20%, 15%)",
|
||||||
|
"mutedForeground": "hsl(25, 10%, 55%)",
|
||||||
|
"accent": "hsl(195, 65%, 55%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"destructive": "hsl(0, 75%, 50%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(25, 25%, 18%)",
|
||||||
|
"input": "hsl(25, 20%, 15%)",
|
||||||
|
"ring": "hsl(15, 85%, 55%)",
|
||||||
|
"sidebar": "hsl(25, 40%, 6%)",
|
||||||
|
"sidebarForeground": "hsl(30, 20%, 90%)",
|
||||||
|
"sidebarPrimary": "hsl(15, 85%, 55%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(25, 30%, 12%)",
|
||||||
|
"sidebarAccentForeground": "hsl(30, 20%, 90%)",
|
||||||
|
"sidebarBorder": "hsl(25, 25%, 15%)",
|
||||||
|
"sidebarRing": "hsl(15, 85%, 55%)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(30, 30%, 96%)",
|
||||||
|
"foreground": "hsl(25, 40%, 15%)",
|
||||||
|
"card": "hsl(0, 0%, 100%)",
|
||||||
|
"cardForeground": "hsl(25, 40%, 15%)",
|
||||||
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
|
"popoverForeground": "hsl(25, 40%, 15%)",
|
||||||
|
"primary": "hsl(15, 80%, 50%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(185, 55%, 45%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"muted": "hsl(30, 15%, 90%)",
|
||||||
|
"mutedForeground": "hsl(25, 10%, 45%)",
|
||||||
|
"accent": "hsl(195, 60%, 50%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"destructive": "hsl(0, 72%, 50%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(30, 15%, 85%)",
|
||||||
|
"input": "hsl(30, 10%, 88%)",
|
||||||
|
"ring": "hsl(15, 80%, 50%)",
|
||||||
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarForeground": "hsl(25, 40%, 15%)",
|
||||||
|
"sidebarPrimary": "hsl(15, 80%, 50%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(30, 15%, 92%)",
|
||||||
|
"sidebarAccentForeground": "hsl(25, 40%, 15%)",
|
||||||
|
"sidebarBorder": "hsl(30, 15%, 85%)",
|
||||||
|
"sidebarRing": "hsl(15, 80%, 50%)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"borderRadius": "0.75rem",
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, sans-serif",
|
||||||
|
"headingFont": "Inter, sans-serif"
|
||||||
|
},
|
||||||
|
"keyColors": {
|
||||||
|
"primary": "#e85d28",
|
||||||
|
"background": "#0f0a06 (dark) / #f5f0eb (light)",
|
||||||
|
"card": "#1a120c (dark) / #ffffff (light)",
|
||||||
|
"sidebar": "#0f0a06 (dark) / #ffffff (light)",
|
||||||
|
"border": "#2a1f15 (dark) / #d9cfc5 (light)",
|
||||||
|
"text": "#e6ddd4 (dark) / #1a110a (light)",
|
||||||
|
"mutedText": "#8a7a6a (dark) / #6b5d50 (light)"
|
||||||
|
},
|
||||||
|
"sunset": {
|
||||||
|
"orange": "#e85d28",
|
||||||
|
"golden": "#f4a340",
|
||||||
|
"turquoise": "#30c5d2",
|
||||||
|
"pink": "#e8677a",
|
||||||
|
"purple": "#8b5cf6",
|
||||||
|
"cream": "#f5e6d0"
|
||||||
|
},
|
||||||
|
"chartSegments": {
|
||||||
|
"open": { "label": "Open", "color": "#e85d28", "description": "Sunset Orange — 35%" },
|
||||||
|
"contacted": { "label": "Contacted", "color": "#30c5d2", "description": "Caribbean Turquoise — 25%" },
|
||||||
|
"pending": { "label": "Pending", "color": "#f4a340", "description": "Golden Hour — 17%" },
|
||||||
|
"closed": { "label": "Closed", "color": "#50b87a", "description": "Palm Green — 23%" },
|
||||||
|
"ignored": { "label": "Ignored", "color": "#8b7a6a", "description": "Warm Muted — 0%" }
|
||||||
|
},
|
||||||
|
"barChart": {
|
||||||
|
"newLeads": { "color": "#e85d28", "glow": "rgba(232, 93, 40, 0.5)" },
|
||||||
|
"closed": { "color": "#30c5d2", "glow": "rgba(48, 197, 210, 0.4)" }
|
||||||
|
},
|
||||||
|
"statusBadges": {
|
||||||
|
"open": { "color": "#30c5d2", "label": "Turquoise" },
|
||||||
|
"contacted": { "color": "#e8677a", "label": "Sunset Pink" },
|
||||||
|
"pending": { "color": "#f4a340", "label": "Golden" },
|
||||||
|
"closed": { "color": "#50b87a", "label": "Palm Green" },
|
||||||
|
"ignored": { "color": "#8b7a6a", "label": "Warm Muted" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,549 @@
|
|||||||
|
.dark.theme-cyber2 {
|
||||||
|
--background: 240 30% 4%;
|
||||||
|
--foreground: 180 50% 95%;
|
||||||
|
--card: 240 25% 8%;
|
||||||
|
--card-foreground: 180 50% 95%;
|
||||||
|
--popover: 240 25% 8%;
|
||||||
|
--popover-foreground: 180 50% 95%;
|
||||||
|
--primary: 280 80% 55%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 280 80% 55%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 240 15% 15%;
|
||||||
|
--muted-foreground: 180 10% 55%;
|
||||||
|
--accent: 280 80% 55%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 180 30% 20%;
|
||||||
|
--input: 180 30% 20%;
|
||||||
|
--ring: 280 80% 55%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 240 30% 4%;
|
||||||
|
--sidebar-foreground: 180 50% 95%;
|
||||||
|
--sidebar-primary: 280 80% 55%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 0%;
|
||||||
|
--sidebar-accent: 280 80% 55%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
|
--sidebar-border: 180 30% 15%;
|
||||||
|
--sidebar-ring: 280 80% 55%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 {
|
||||||
|
--background: 180 20% 96%;
|
||||||
|
--foreground: 240 40% 10%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 40% 10%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 40% 10%;
|
||||||
|
--primary: 280 60% 50%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 280 60% 50%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 180 15% 88%;
|
||||||
|
--muted-foreground: 240 10% 45%;
|
||||||
|
--accent: 280 60% 50%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 80% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 180 15% 82%;
|
||||||
|
--input: 180 15% 82%;
|
||||||
|
--ring: 280 60% 50%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 240 40% 10%;
|
||||||
|
--sidebar-primary: 280 60% 50%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 280 60% 50%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
|
--sidebar-border: 180 15% 82%;
|
||||||
|
--sidebar-ring: 280 60% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 body {
|
||||||
|
background: transparent
|
||||||
|
linear-gradient(135deg, rgba(0, 10, 20, 0.95), rgba(10, 0, 20, 0.9));
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 body > div {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -2;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(0deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(0, 255, 255, 0.03) 1px, transparent 1px);
|
||||||
|
background-size: 40px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 50% at 10% 20%, rgba(0, 255, 255, 0.08) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 90% 80%, rgba(255, 0, 255, 0.08) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 50% 50%, rgba(0, 255, 255, 0.04) 0%, transparent 50%),
|
||||||
|
radial-gradient(20px 20px at 15% 15%, rgba(0, 255, 255, 0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(15px 15px at 85% 25%, rgba(255, 0, 255, 0.12) 0%, transparent 100%),
|
||||||
|
radial-gradient(12px 12px at 45% 75%, rgba(0, 255, 255, 0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(10px 10px at 70% 45%, rgba(255, 0, 255, 0.08) 0%, transparent 100%),
|
||||||
|
radial-gradient(8px 8px at 30% 60%, rgba(0, 255, 255, 0.12) 0%, transparent 100%),
|
||||||
|
radial-gradient(11px 11px at 60% 10%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(9px 9px at 90% 65%, rgba(0, 255, 255, 0.08) 0%, transparent 100%),
|
||||||
|
radial-gradient(13px 13px at 5% 85%, rgba(255, 0, 255, 0.1) 0%, transparent 100%),
|
||||||
|
radial-gradient(7px 7px at 55% 55%, rgba(0, 255, 255, 0.06) 0%, transparent 100%);
|
||||||
|
animation: neon-drift 20s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 15% 20%, rgba(0, 200, 200, 0.05) 0%, transparent 60%),
|
||||||
|
radial-gradient(ellipse 80% 50% at 85% 80%, rgba(200, 0, 200, 0.04) 0%, transparent 60%),
|
||||||
|
repeating-linear-gradient(0deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px),
|
||||||
|
repeating-linear-gradient(90deg, transparent, transparent 40px, rgba(0, 200, 200, 0.02) 40px, rgba(0, 200, 200, 0.02) 41px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes neon-drift {
|
||||||
|
0% { background-position: 0% 0%; opacity: 0.6; }
|
||||||
|
50% { background-position: 100% 100%; opacity: 1; }
|
||||||
|
100% { background-position: 0% 100%; opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .text-glow {
|
||||||
|
text-shadow: 0 0 10px hsl(180, 100%, 50%), 0 0 30px hsl(180, 100%, 50%), 0 0 60px rgba(0, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .text-glow {
|
||||||
|
text-shadow: 0 0 8px hsl(180, 100%, 40%), 0 0 20px hsl(180, 100%, 40%), 0 0 40px rgba(0, 200, 200, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 h1, .dark.theme-cyber2 h2, .dark.theme-cyber2 h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(180, 100%, 50%), hsl(280, 80%, 55%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 h1, .light.theme-cyber2 h2, .light.theme-cyber2 h3 {
|
||||||
|
background: linear-gradient(135deg, hsl(180, 100%, 35%), hsl(280, 60%, 45%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 a {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 a {
|
||||||
|
color: hsl(180, 100%, 35%);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 a:hover {
|
||||||
|
color: hsl(280, 80%, 60%);
|
||||||
|
text-shadow: 0 0 8px rgba(255, 0, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 a:hover {
|
||||||
|
color: hsl(280, 60%, 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 blockquote {
|
||||||
|
border-left: 3px solid hsl(280, 80%, 55%);
|
||||||
|
color: hsl(180, 20%, 70%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 blockquote {
|
||||||
|
border-left: 3px solid hsl(280, 60%, 50%);
|
||||||
|
color: hsl(240, 10%, 45%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 code {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
background: rgba(0, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 code {
|
||||||
|
color: hsl(280, 60%, 45%);
|
||||||
|
background: rgba(0, 200, 200, 0.05);
|
||||||
|
border: 1px solid rgba(0, 200, 200, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 pre {
|
||||||
|
background: rgba(0, 5, 15, 0.9);
|
||||||
|
border: 1px solid rgba(0, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 pre {
|
||||||
|
background: rgba(0, 200, 200, 0.03);
|
||||||
|
border: 1px solid rgba(0, 200, 200, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 hr {
|
||||||
|
border-color: rgba(0, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 hr {
|
||||||
|
border-color: rgba(0, 200, 200, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 table th {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 table th {
|
||||||
|
color: hsl(180, 100%, 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 table td {
|
||||||
|
border-color: rgba(0, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 table td {
|
||||||
|
border-color: rgba(0, 200, 200, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 ::selection {
|
||||||
|
background: rgba(0, 255, 255, 0.2);
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 ::selection {
|
||||||
|
background: rgba(0, 200, 200, 0.12);
|
||||||
|
color: hsl(0, 0%, 0%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 input::placeholder,
|
||||||
|
.dark.theme-cyber2 textarea::placeholder {
|
||||||
|
color: hsl(180, 20%, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 input::placeholder,
|
||||||
|
.light.theme-cyber2 textarea::placeholder {
|
||||||
|
color: hsl(240, 10%, 65%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .page-header-title {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .page-header-title {
|
||||||
|
color: hsl(180, 100%, 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .stat-card-value {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .stat-card-value {
|
||||||
|
color: hsl(180, 100%, 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .badge {
|
||||||
|
color: hsl(180, 100%, 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .badge {
|
||||||
|
color: hsl(180, 100%, 35%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(180, 100%, 70%), hsl(180, 100%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 80%, 55%), hsl(280, 80%, 70%), hsl(280, 80%, 55%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(180, 100%, 50%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(280, 80%, 55%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(180, 100%, 50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(280, 80%, 55%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(280, 80%, 55%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||||
|
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(180, 100%, 40%), hsl(180, 100%, 55%), hsl(180, 100%, 40%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(280, 60%, 50%), hsl(280, 60%, 65%), hsl(280, 60%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .growth-word,
|
||||||
|
.light.theme-cyber2 .growth-word {
|
||||||
|
-webkit-text-fill-color: #1BB0CE !important;
|
||||||
|
color: #1BB0CE !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Charts — Donut (LeadStatus) & Bar (LeadsPerMonth) — Cyber2 colors
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* ---- SVG gradient overrides (donut chart segments) ---- */
|
||||||
|
.dark.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 80%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 90%, 60%) !important; }
|
||||||
|
.dark.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 20%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 #chart-grad-0 stop { stop-color: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 #chart-grad-1 stop { stop-color: hsl(280, 60%, 50%) !important; }
|
||||||
|
.light.theme-cyber2 #chart-grad-2 stop { stop-color: hsl(310, 70%, 55%) !important; }
|
||||||
|
.light.theme-cyber2 #chart-grad-3 stop { stop-color: hsl(170, 80%, 38%) !important; }
|
||||||
|
.light.theme-cyber2 #chart-grad-4 stop { stop-color: hsl(180, 15%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ---- SVG gradient overrides (bar chart) ---- */
|
||||||
|
.dark.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 80%, 55%) !important; }
|
||||||
|
.light.theme-cyber2 #newLeadsGrad stop { stop-color: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 #closedGrad stop { stop-color: hsl(280, 60%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ---- Inline chart colors (legend dots, hover text) ---- */
|
||||||
|
.dark.theme-cyber2 [style*="background: #3b82f6"],
|
||||||
|
.dark.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="background: #f59e0b"],
|
||||||
|
.dark.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 80%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="background: #8b5cf6"],
|
||||||
|
.dark.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 90%, 60%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="background: #10b981"],
|
||||||
|
.dark.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="background: #6B7280"],
|
||||||
|
.dark.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 20%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 [style*="background: #3b82f6"],
|
||||||
|
.light.theme-cyber2 [style*="background:#3b82f6"] { background: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="background: #f59e0b"],
|
||||||
|
.light.theme-cyber2 [style*="background:#f59e0b"] { background: hsl(280, 60%, 50%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="background: #8b5cf6"],
|
||||||
|
.light.theme-cyber2 [style*="background:#8b5cf6"] { background: hsl(310, 70%, 55%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="background: #10b981"],
|
||||||
|
.light.theme-cyber2 [style*="background:#10b981"] { background: hsl(170, 80%, 38%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="background: #6B7280"],
|
||||||
|
.light.theme-cyber2 [style*="background:#6B7280"] { background: hsl(180, 15%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ---- Chart legend hover borders ---- */
|
||||||
|
.dark.theme-cyber2 [style*="border: 1px solid #3b82f6"],
|
||||||
|
.dark.theme-cyber2 [style*="border:1px solid #3b82f6"] { border-color: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="border: 1px solid #f59e0b"],
|
||||||
|
.dark.theme-cyber2 [style*="border:1px solid #f59e0b"] { border-color: hsl(280, 80%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="border: 1px solid #8b5cf6"],
|
||||||
|
.dark.theme-cyber2 [style*="border:1px solid #8b5cf6"] { border-color: hsl(310, 90%, 60%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="border: 1px solid #10b981"],
|
||||||
|
.dark.theme-cyber2 [style*="border:1px solid #10b981"] { border-color: hsl(170, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="border: 1px solid #6B7280"],
|
||||||
|
.dark.theme-cyber2 [style*="border:1px solid #6B7280"] { border-color: hsl(180, 20%, 40%) !important; }
|
||||||
|
|
||||||
|
/* ---- Donut chart segment text color on hover ---- */
|
||||||
|
.dark.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 80%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 90%, 60%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 100%, 45%) !important; }
|
||||||
|
.dark.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 20%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 [style*="color: #3b82f6"] { color: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="color: #f59e0b"] { color: hsl(280, 60%, 50%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="color: #8b5cf6"] { color: hsl(310, 70%, 55%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="color: #10b981"] { color: hsl(170, 80%, 38%) !important; }
|
||||||
|
.light.theme-cyber2 [style*="color: #6B7280"] { color: hsl(180, 15%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Stat Cards — Icon backgrounds, text colors, progress bars
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(180, 100%, 50%, 0.12) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(180, 100%, 50%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||||
|
background: linear-gradient(to right, hsl(180, 100%, 50%), hsl(280, 80%, 55%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(180, 100%, 50%, 0.2), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(180, 80%, 40%, 0.1) !important;
|
||||||
|
}
|
||||||
|
.light.theme-cyber2 .rounded-xl.border.bg-card .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(180, 80%, 40%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .rounded-xl.border.bg-card .h-1\.5.bg-muted.rounded-full .rounded-full {
|
||||||
|
background: linear-gradient(to right, hsl(180, 80%, 40%), hsl(280, 60%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .rounded-xl.border.bg-card .absolute.bottom-0.left-0.right-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(180, 80%, 40%, 0.15), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Inline hex color overrides — stat card values, page icons, etc.
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .text-\[#C84B4B\], .dark.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 .text-\[#5A8FC4\], .dark.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 80%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 .text-\[#FF1111\], .dark.theme-cyber2 .dark\:text-\[\#FF1111\] { color: hsl(180, 100%, 55%) !important; }
|
||||||
|
.dark.theme-cyber2 .text-\[#1144FF\], .dark.theme-cyber2 .dark\:text-\[\#1144FF\] { color: hsl(280, 80%, 60%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 .text-\[#C84B4B\], .light.theme-cyber2 .dark\:text-\[\#C84B4B\] { color: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 .text-\[#5A8FC4\], .light.theme-cyber2 .dark\:text-\[\#5A8FC4\] { color: hsl(280, 60%, 50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .bg-\[#C84B4B\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 100%, 50%, 0.1) !important; }
|
||||||
|
.dark.theme-cyber2 .bg-\[#5A8FC4\]\/10, .dark.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 80%, 55%, 0.1) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 .bg-\[#C84B4B\]\/10, .light.theme-cyber2 .dark\:bg-\[\#C84B4B\]\/10 { background: hsla(180, 80%, 40%, 0.08) !important; }
|
||||||
|
.light.theme-cyber2 .bg-\[#5A8FC4\]\/10, .light.theme-cyber2 .dark\:bg-\[\#5A8FC4\]\/10 { background: hsla(280, 60%, 50%, 0.08) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .from-\[#C84B4B\], .dark.theme-cyber2 .dark\:from-\[\#C84B4B\],
|
||||||
|
.dark.theme-cyber2 .via-\[#C84B4B\], .dark.theme-cyber2 .dark\:via-\[\#C84B4B\],
|
||||||
|
.dark.theme-cyber2 .to-\[#C84B4B\], .dark.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 100%, 50%) !important; }
|
||||||
|
|
||||||
|
.dark.theme-cyber2 .from-\[#5A8FC4\], .dark.theme-cyber2 .dark\:from-\[\#5A8FC4\],
|
||||||
|
.dark.theme-cyber2 .via-\[#5A8FC4\], .dark.theme-cyber2 .dark\:via-\[\#5A8FC4\],
|
||||||
|
.dark.theme-cyber2 .to-\[#5A8FC4\], .dark.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 80%, 55%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 .from-\[#C84B4B\], .light.theme-cyber2 .dark\:from-\[\#C84B4B\],
|
||||||
|
.light.theme-cyber2 .via-\[#C84B4B\], .light.theme-cyber2 .dark\:via-\[\#C84B4B\],
|
||||||
|
.light.theme-cyber2 .to-\[#C84B4B\], .light.theme-cyber2 .dark\:to-\[\#C84B4B\] { --tw-gradient-from: hsl(180, 80%, 40%) !important; }
|
||||||
|
|
||||||
|
.light.theme-cyber2 .from-\[#5A8FC4\], .light.theme-cyber2 .dark\:from-\[\#5A8FC4\],
|
||||||
|
.light.theme-cyber2 .via-\[#5A8FC4\], .light.theme-cyber2 .dark\:via-\[\#5A8FC4\],
|
||||||
|
.light.theme-cyber2 .to-\[#5A8FC4\], .light.theme-cyber2 .dark\:to-\[\#5A8FC4\] { --tw-gradient-from: hsl(280, 60%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ---- SVG stroke/fill overrides ---- */
|
||||||
|
.dark.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 100%, 50%) !important; }
|
||||||
|
.dark.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 80%, 55%) !important; }
|
||||||
|
.light.theme-cyber2 [stroke="#CC0000"] { stroke: hsl(180, 80%, 40%) !important; }
|
||||||
|
.light.theme-cyber2 [stroke="#0033CC"] { stroke: hsl(280, 60%, 50%) !important; }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Light mode — deeper visual polish
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.light.theme-cyber2 .rounded-xl.border.bg-card {
|
||||||
|
box-shadow: 0 1px 3px hsla(180, 80%, 30%, 0.08), 0 4px 12px hsla(180, 80%, 30%, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 aside .group:hover {
|
||||||
|
background: hsla(180, 80%, 40%, 0.04) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-cyber2 .hover\:bg-accent:hover {
|
||||||
|
background: hsla(180, 80%, 40%, 0.06) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Client Portal Overrides — Background for portal pages
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Make the outer portal container transparent so body::before background shows */
|
||||||
|
.dark.theme-cyber2 .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar — dark glass */
|
||||||
|
.dark.theme-cyber2 aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-right-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar nav items — muted text */
|
||||||
|
.dark.theme-cyber2 aside .text-muted-foreground {
|
||||||
|
color: hsl(240, 8%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cyber2 aside button:hover {
|
||||||
|
background: hsla(330, 100%, 50%, 0.08) !important;
|
||||||
|
color: hsl(0, 0%, 90%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar active nav item */
|
||||||
|
.dark.theme-cyber2 aside .bg-primary\/10.text-primary {
|
||||||
|
background: hsl(330, 100%, 50%) !important;
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 127, 0.3), 0 0 20px rgba(255, 0, 127, 0.1);
|
||||||
|
border-color: hsl(330, 100%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-cyber2 aside .bg-primary\/10.text-primary svg {
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal header — dark glass */
|
||||||
|
.dark.theme-cyber2 header.bg-card\/80 {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal card backgrounds — semi-transparent glass */
|
||||||
|
.dark.theme-cyber2 .bg-card {
|
||||||
|
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal borders */
|
||||||
|
.dark.theme-cyber2 .border-border {
|
||||||
|
border-color: hsl(270, 25%, 12%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal input fields */
|
||||||
|
.dark.theme-cyber2 .bg-muted {
|
||||||
|
background: hsl(240, 12%, 9%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal primary buttons */
|
||||||
|
.dark.theme-cyber2 .bg-primary {
|
||||||
|
background: hsl(330, 100%, 50%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 127, 0.2);
|
||||||
|
}
|
||||||
|
.dark.theme-cyber2 .bg-primary:hover {
|
||||||
|
background: hsl(330, 100%, 55%) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 0, 127, 0.4);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "Cyberpunk",
|
||||||
|
"description": "Neon-cyberpunk theme with cyan grid, magenta accents, and futuristic glow",
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "240 30% 4%",
|
||||||
|
"foreground": "180 50% 95%",
|
||||||
|
"primary": "180 100% 50%",
|
||||||
|
"secondary": "280 80% 55%",
|
||||||
|
"accent": "280 80% 55%",
|
||||||
|
"muted": "240 15% 15%",
|
||||||
|
"border": "180 30% 20%",
|
||||||
|
"ring": "180 100% 50%",
|
||||||
|
"card": "240 25% 8%",
|
||||||
|
"popover": "240 25% 8%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(180,100%,50%), hsl(280,80%,55%))",
|
||||||
|
"link": "hsl(180, 100%, 55%)",
|
||||||
|
"link-hover": "hsl(280, 80%, 60%)",
|
||||||
|
"code": "hsl(180, 100%, 55%)",
|
||||||
|
"blockquote": "hsl(180, 20%, 70%)",
|
||||||
|
"table-header": "hsl(180, 100%, 55%)",
|
||||||
|
"stat-card-value": "hsl(180, 100%, 55%)",
|
||||||
|
"badge": "hsl(180, 100%, 55%)",
|
||||||
|
"page-header-title": "hsl(180, 100%, 55%)",
|
||||||
|
"text-glow": "0 0 10px hsl(180,100%,50%), 0 0 30px hsl(180,100%,50%), 0 0 60px rgba(0,255,255,0.5)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "Deep navy-black with cyan grid lines, cyan and magenta neon glow gradients, and glowing dot particles",
|
||||||
|
"grid-color": "rgba(0, 255, 255, 0.03)",
|
||||||
|
"grid-size": "40px",
|
||||||
|
"glow-colors": ["rgba(0, 255, 255, 0.08)", "rgba(255, 0, 255, 0.08)"]
|
||||||
|
},
|
||||||
|
"effects": {
|
||||||
|
"body-background-animation": "neon-drift 20s ease-in-out infinite alternate"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "180 20% 96%",
|
||||||
|
"foreground": "240 40% 10%",
|
||||||
|
"primary": "180 100% 40%",
|
||||||
|
"secondary": "280 60% 50%",
|
||||||
|
"accent": "280 60% 50%",
|
||||||
|
"muted": "180 15% 88%",
|
||||||
|
"border": "180 15% 82%",
|
||||||
|
"ring": "180 100% 40%",
|
||||||
|
"card": "0 0% 100%",
|
||||||
|
"popover": "0 0% 100%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(180,100%,35%), hsl(280,60%,45%))",
|
||||||
|
"link": "hsl(180, 100%, 35%)",
|
||||||
|
"link-hover": "hsl(280, 60%, 50%)",
|
||||||
|
"code": "hsl(280, 60%, 45%)",
|
||||||
|
"blockquote": "hsl(240, 10%, 45%)",
|
||||||
|
"table-header": "hsl(180, 100%, 35%)",
|
||||||
|
"stat-card-value": "hsl(180, 100%, 35%)",
|
||||||
|
"badge": "hsl(180, 100%, 35%)",
|
||||||
|
"page-header-title": "hsl(180, 100%, 35%)",
|
||||||
|
"text-glow": "0 0 8px hsl(180,100%,40%), 0 0 20px hsl(180,100%,40%), 0 0 40px rgba(0,200,200,0.3)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "Light with subtle cyan and magenta gradient washes and faint grid lines",
|
||||||
|
"glow-colors": ["rgba(0, 200, 200, 0.05)", "rgba(200, 0, 200, 0.04)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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%;
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"theme": "forest-minimal",
|
||||||
|
"name": "Forest Minimal",
|
||||||
|
"colors": {
|
||||||
|
"background": "84 25% 97%",
|
||||||
|
"surface": "0 0% 100%",
|
||||||
|
"surface-2": "84 20% 96%",
|
||||||
|
"primary": "84 50% 53%",
|
||||||
|
"primary-hover": "84 50% 46%",
|
||||||
|
"primary-light": "84 53% 92%",
|
||||||
|
"success": "122 39% 49%",
|
||||||
|
"text-primary": "#1F2937",
|
||||||
|
"text-secondary": "#6B7280",
|
||||||
|
"card-border": "#EEF1EC",
|
||||||
|
"background-dark": "#EAF3E5",
|
||||||
|
"background-light": "#F5F9F2",
|
||||||
|
"sage-light": "#E8F0E2",
|
||||||
|
"mint-pale": "#EEF5EA",
|
||||||
|
"foliage-dark": "#5A7D5F",
|
||||||
|
"foliage-mid": "#65896A",
|
||||||
|
"foliage-light": "#7B9E7D",
|
||||||
|
"foliage-pale": "#90B090",
|
||||||
|
"scandi-sage": "#C8DCC8"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"id": "luminous",
|
||||||
|
"name": "Luminous",
|
||||||
|
"description": "Deep Glass / Glassmorphism 2.0 — blue-teal-amber palette with specular highlights, 3D bevel/emboss borders, refraction effects, and ultra-frosted translucent surfaces inspired by iOS & HarmonyOS",
|
||||||
|
"type": "dual",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(215, 30%, 6%)",
|
||||||
|
"foreground": "hsl(210, 30%, 96%)",
|
||||||
|
"card": "hsl(215, 25%, 10%)",
|
||||||
|
"cardForeground": "hsl(210, 30%, 96%)",
|
||||||
|
"popover": "hsl(215, 25%, 10%)",
|
||||||
|
"popoverForeground": "hsl(210, 30%, 96%)",
|
||||||
|
"primary": "hsl(210, 75%, 55%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(185, 70%, 45%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"muted": "hsl(215, 15%, 14%)",
|
||||||
|
"mutedForeground": "hsl(215, 12%, 55%)",
|
||||||
|
"accent": "hsl(35, 80%, 55%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"destructive": "hsl(0, 75%, 50%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(215, 18%, 20%)",
|
||||||
|
"input": "hsl(215, 15%, 16%)",
|
||||||
|
"ring": "hsl(210, 75%, 55%)",
|
||||||
|
"sidebar": "hsl(215, 30%, 6%)",
|
||||||
|
"sidebarForeground": "hsl(210, 30%, 96%)",
|
||||||
|
"sidebarPrimary": "hsl(210, 75%, 55%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(210, 35%, 16%)",
|
||||||
|
"sidebarAccentForeground": "hsl(210, 30%, 96%)",
|
||||||
|
"sidebarBorder": "hsl(215, 18%, 16%)",
|
||||||
|
"sidebarRing": "hsl(210, 75%, 55%)"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, #60a5fa, #2dd4bf)",
|
||||||
|
"body": "#e2e8f0",
|
||||||
|
"muted": "#7a8a9a",
|
||||||
|
"link": "#60a5fa",
|
||||||
|
"linkHover": "#f59e0b",
|
||||||
|
"code": "#2dd4bf",
|
||||||
|
"placeholder": "#5a6a7a"
|
||||||
|
},
|
||||||
|
"backgroundImage": null,
|
||||||
|
"backgroundGradients": [
|
||||||
|
"rgba(50, 140, 240, 0.18) at 10% 20%",
|
||||||
|
"rgba(0, 200, 210, 0.10) at 85% 75%",
|
||||||
|
"rgba(200, 160, 40, 0.06) at 50% 15%",
|
||||||
|
"rgba(50, 140, 240, 0.07) at 35% 85%"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(205, 38%, 86%)",
|
||||||
|
"foreground": "hsl(210, 30%, 15%)",
|
||||||
|
"card": "hsl(0, 0%, 100%)",
|
||||||
|
"cardForeground": "hsl(210, 30%, 15%)",
|
||||||
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
|
"popoverForeground": "hsl(210, 30%, 15%)",
|
||||||
|
"primary": "hsl(210, 65%, 48%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(185, 65%, 38%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"muted": "hsl(205, 20%, 90%)",
|
||||||
|
"mutedForeground": "hsl(210, 12%, 48%)",
|
||||||
|
"accent": "hsl(35, 75%, 50%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"destructive": "hsl(0, 75%, 45%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(210, 18%, 78%)",
|
||||||
|
"input": "hsl(210, 15%, 84%)",
|
||||||
|
"ring": "hsl(210, 65%, 48%)",
|
||||||
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarForeground": "hsl(210, 30%, 15%)",
|
||||||
|
"sidebarPrimary": "hsl(210, 65%, 48%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(205, 18%, 90%)",
|
||||||
|
"sidebarAccentForeground": "hsl(210, 30%, 15%)",
|
||||||
|
"sidebarBorder": "hsl(210, 18%, 78%)",
|
||||||
|
"sidebarRing": "hsl(210, 65%, 48%)"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, #3b82f6, #0d9488)",
|
||||||
|
"body": "#1e293b",
|
||||||
|
"muted": "#6b7280",
|
||||||
|
"link": "#3b82f6",
|
||||||
|
"linkHover": "#d97706",
|
||||||
|
"code": "#3b82f6",
|
||||||
|
"placeholder": "#9ca3af"
|
||||||
|
},
|
||||||
|
"backgroundImage": null,
|
||||||
|
"backgroundGradients": [
|
||||||
|
"rgba(50, 140, 240, 0.06) at 15% 20%",
|
||||||
|
"rgba(0, 200, 210, 0.04) at 85% 75%",
|
||||||
|
"rgba(200, 160, 40, 0.02) at 50% 50%"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"borderRadius": "0.75rem",
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, sans-serif",
|
||||||
|
"headingFont": "Inter, sans-serif"
|
||||||
|
},
|
||||||
|
"keyColors": {
|
||||||
|
"primary": "#60a5fa (dark) / #3b82f6 (light)",
|
||||||
|
"background": "#0b111e (dark) / #d2e2f2 (light)",
|
||||||
|
"card": "#121a28 (dark) / #ffffff (light)",
|
||||||
|
"sidebar": "#0b111e (dark) / #ffffff (light)",
|
||||||
|
"border": "#283040 (dark) / #d4d4dc (light)",
|
||||||
|
"text": "#e2e8f0 (dark) / #1e293b (light)",
|
||||||
|
"mutedText": "#7a8a9a (dark) / #6b7280 (light)",
|
||||||
|
"headingGradient": "#60a5fa → #2dd4bf (dark) / #3b82f6 → #0d9488 (light)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
.dark.theme-pumpkin {
|
||||||
|
--background: 30 40% 3%;
|
||||||
|
--foreground: 30 30% 92%;
|
||||||
|
--card: 25 30% 7%;
|
||||||
|
--card-foreground: 30 30% 92%;
|
||||||
|
--popover: 25 30% 7%;
|
||||||
|
--popover-foreground: 30 30% 92%;
|
||||||
|
--primary: 25 95% 50%;
|
||||||
|
--primary-foreground: 30 20% 5%;
|
||||||
|
--secondary: 35 100% 45%;
|
||||||
|
--secondary-foreground: 30 20% 5%;
|
||||||
|
--muted: 25 15% 12%;
|
||||||
|
--muted-foreground: 30 12% 60%;
|
||||||
|
--accent: 15 100% 45%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 70% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 25 20% 16%;
|
||||||
|
--input: 25 20% 16%;
|
||||||
|
--ring: 25 95% 50%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 25 35% 4%;
|
||||||
|
--sidebar-foreground: 30 30% 92%;
|
||||||
|
--sidebar-primary: 25 95% 50%;
|
||||||
|
--sidebar-primary-foreground: 30 20% 5%;
|
||||||
|
--sidebar-accent: 15 100% 45%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
|
--sidebar-border: 25 18% 14%;
|
||||||
|
--sidebar-ring: 25 95% 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin {
|
||||||
|
--background: 30 30% 94%;
|
||||||
|
--foreground: 25 40% 12%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 25 40% 12%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 25 40% 12%;
|
||||||
|
--primary: 25 90% 42%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 35 90% 42%;
|
||||||
|
--secondary-foreground: 0 0% 100%;
|
||||||
|
--muted: 30 15% 86%;
|
||||||
|
--muted-foreground: 25 10% 42%;
|
||||||
|
--accent: 15 90% 42%;
|
||||||
|
--accent-foreground: 0 0% 100%;
|
||||||
|
--destructive: 0 80% 50%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 25 15% 80%;
|
||||||
|
--input: 25 15% 80%;
|
||||||
|
--ring: 25 90% 42%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 25 40% 12%;
|
||||||
|
--sidebar-primary: 25 90% 42%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 15 90% 42%;
|
||||||
|
--sidebar-accent-foreground: 0 0% 100%;
|
||||||
|
--sidebar-border: 25 15% 80%;
|
||||||
|
--sidebar-ring: 25 90% 42%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warm ambient glow */
|
||||||
|
.dark.theme-pumpkin body {
|
||||||
|
background: transparent
|
||||||
|
radial-gradient(ellipse 100% 55% at 20% 25%, rgba(200, 80, 0, 0.18) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 80% 45% at 80% 75%, rgba(255, 140, 0, 0.12) 0%, transparent 45%),
|
||||||
|
radial-gradient(ellipse 60% 35% at 50% 50%, rgba(180, 60, 0, 0.08) 0%, transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin body > div {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 70% 45% at 15% 20%, rgba(200, 80, 0, 0.05) 0%, transparent 55%),
|
||||||
|
radial-gradient(ellipse 70% 45% at 85% 80%, rgba(255, 140, 0, 0.04) 0%, transparent 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pumpkin Spice text glow */
|
||||||
|
.dark.theme-pumpkin .text-glow {
|
||||||
|
text-shadow: 0 0 10px hsl(25, 95%, 50%), 0 0 30px hsla(25, 95%, 50%, 0.6), 0 0 60px rgba(200, 80, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin .text-glow {
|
||||||
|
text-shadow: 0 0 8px hsl(25, 90%, 42%), 0 0 20px hsla(25, 90%, 42%, 0.4), 0 0 40px rgba(200, 80, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient headings — warm orange to amber */
|
||||||
|
.dark.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
|
||||||
|
.dark.theme-pumpkin h2:not(.text-primary):not(.text-foreground),
|
||||||
|
.dark.theme-pumpkin h3:not(.text-primary):not(.text-foreground) {
|
||||||
|
background: linear-gradient(135deg, hsl(25, 95%, 55%), hsl(35, 100%, 50%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin h1:not(.text-primary):not(.text-foreground),
|
||||||
|
.light.theme-pumpkin h2:not(.text-primary):not(.text-foreground),
|
||||||
|
.light.theme-pumpkin h3:not(.text-primary):not(.text-foreground) {
|
||||||
|
background: linear-gradient(135deg, hsl(25, 90%, 40%), hsl(35, 90%, 40%));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
.dark.theme-pumpkin a { color: hsl(25, 95%, 55%); }
|
||||||
|
.light.theme-pumpkin a { color: hsl(25, 90%, 40%); }
|
||||||
|
.dark.theme-pumpkin a:hover { color: hsl(35, 100%, 55%); text-shadow: 0 0 8px rgba(255, 140, 0, 0.35); }
|
||||||
|
.light.theme-pumpkin a:hover { color: hsl(35, 90%, 45%); }
|
||||||
|
|
||||||
|
/* Blockquotes */
|
||||||
|
.dark.theme-pumpkin blockquote {
|
||||||
|
border-left: 3px solid hsl(25, 95%, 50%);
|
||||||
|
color: hsl(30, 15%, 70%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.light.theme-pumpkin blockquote {
|
||||||
|
border-left: 3px solid hsl(25, 90%, 42%);
|
||||||
|
color: hsl(25, 10%, 42%);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code */
|
||||||
|
.dark.theme-pumpkin code {
|
||||||
|
color: hsl(25, 95%, 55%);
|
||||||
|
background: rgba(200, 80, 0, 0.1);
|
||||||
|
border: 1px solid rgba(200, 80, 0, 0.15);
|
||||||
|
}
|
||||||
|
.light.theme-pumpkin code {
|
||||||
|
color: hsl(25, 90%, 40%);
|
||||||
|
background: rgba(200, 80, 0, 0.04);
|
||||||
|
border: 1px solid rgba(200, 80, 0, 0.1);
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin pre { background: rgba(25, 12, 0, 0.9); border: 1px solid rgba(200, 80, 0, 0.1); }
|
||||||
|
.light.theme-pumpkin pre { background: rgba(200, 80, 0, 0.03); border: 1px solid rgba(200, 80, 0, 0.08); }
|
||||||
|
|
||||||
|
/* HR */
|
||||||
|
.dark.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.12); }
|
||||||
|
.light.theme-pumpkin hr { border-color: rgba(200, 80, 0, 0.08); }
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
.dark.theme-pumpkin table th { color: hsl(25, 95%, 55%); }
|
||||||
|
.light.theme-pumpkin table th { color: hsl(25, 90%, 40%); }
|
||||||
|
.dark.theme-pumpkin table td { border-color: rgba(200, 80, 0, 0.1); }
|
||||||
|
.light.theme-pumpkin table td { border-color: rgba(200, 80, 0, 0.08); }
|
||||||
|
|
||||||
|
/* Selection */
|
||||||
|
.dark.theme-pumpkin ::selection { background: rgba(200, 80, 0, 0.2); color: hsl(0, 0%, 100%); }
|
||||||
|
.light.theme-pumpkin ::selection { background: rgba(200, 80, 0, 0.1); color: hsl(0, 0%, 0%); }
|
||||||
|
|
||||||
|
/* Placeholders */
|
||||||
|
.dark.theme-pumpkin input::placeholder, .dark.theme-pumpkin textarea::placeholder { color: hsl(25, 10%, 40%); }
|
||||||
|
.light.theme-pumpkin input::placeholder, .light.theme-pumpkin textarea::placeholder { color: hsl(25, 10%, 62%); }
|
||||||
|
|
||||||
|
/* Page header / stat card / badge */
|
||||||
|
.dark.theme-pumpkin .page-header-title, .dark.theme-pumpkin .stat-card-value, .dark.theme-pumpkin .badge { color: hsl(25, 95%, 55%); }
|
||||||
|
.light.theme-pumpkin .page-header-title, .light.theme-pumpkin .stat-card-value, .light.theme-pumpkin .badge { color: hsl(25, 90%, 40%); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Dashboard stat card pipeline bars — Pumpkin Spice palette
|
||||||
|
============================================================ */
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(25, 95%, 65%), hsl(25, 95%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(35, 100%, 45%), hsl(35, 100%, 60%), hsl(35, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(25, 95%, 50%, 0.12) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(35, 100%, 45%, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(35, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(25, 95%, 50%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(35, 100%, 45%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||||
|
background: linear-gradient(to right, hsl(25, 95%, 50%), hsl(35, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(25, 90%, 42%), hsl(25, 90%, 55%), hsl(25, 90%, 42%)) !important;
|
||||||
|
}
|
||||||
|
.light.theme-pumpkin .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(35, 90%, 42%), hsl(35, 90%, 55%), hsl(35, 90%, 42%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Chart color overrides — Pumpkin Spice palette
|
||||||
|
============================================================ */
|
||||||
|
.dark.theme-pumpkin [class*="recharts-"] text,
|
||||||
|
.light.theme-pumpkin [class*="recharts-"] text {
|
||||||
|
fill: hsl(30, 12%, 60%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Donut chart gradient stops */
|
||||||
|
.dark.theme-pumpkin .recharts-pie-sector:nth-child(1) path,
|
||||||
|
.light.theme-pumpkin .recharts-pie-sector:nth-child(1) path {
|
||||||
|
fill: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .recharts-pie-sector:nth-child(2) path,
|
||||||
|
.light.theme-pumpkin .recharts-pie-sector:nth-child(2) path {
|
||||||
|
fill: hsl(35, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .recharts-pie-sector:nth-child(3) path,
|
||||||
|
.light.theme-pumpkin .recharts-pie-sector:nth-child(3) path {
|
||||||
|
fill: hsl(15, 100%, 42%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat card icon colors */
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .h-8.w-8 {
|
||||||
|
background: hsla(25, 95%, 50%, 0.12) !important;
|
||||||
|
color: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .h-8.w-8 {
|
||||||
|
background: hsla(35, 100%, 45%, 0.12) !important;
|
||||||
|
color: hsl(35, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .h-8.w-8 {
|
||||||
|
background: hsla(15, 100%, 42%, 0.12) !important;
|
||||||
|
color: hsl(15, 100%, 42%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stat card value colors */
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(1) .text-2xl {
|
||||||
|
color: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(2) .text-2xl {
|
||||||
|
color: hsl(35, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl,
|
||||||
|
.light.theme-pumpkin .grid.grid-cols-1.sm\:grid-cols-2.lg\:grid-cols-4.gap-4 > div:nth-child(3) .text-2xl {
|
||||||
|
color: hsl(15, 100%, 42%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bar chart bar fills */
|
||||||
|
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect,
|
||||||
|
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(1) rect {
|
||||||
|
fill: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect,
|
||||||
|
.light.theme-pumpkin .recharts-bar-rectangle:nth-child(2) rect {
|
||||||
|
fill: hsl(35, 100%, 45%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Card gradient accent — subtle pumpkin glow on hover
|
||||||
|
============================================================ */
|
||||||
|
.dark.theme-pumpkin .rounded-xl.border.border-border {
|
||||||
|
border-color: hsla(25, 20%, 18%, 0.8) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .rounded-xl.border.border-border:hover {
|
||||||
|
border-color: hsla(25, 95%, 50%, 0.25) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Growth word on login page — keep brand blue */
|
||||||
|
.dark.theme-pumpkin .growth-word,
|
||||||
|
.light.theme-pumpkin .growth-word {
|
||||||
|
-webkit-text-fill-color: #1BB0CE !important;
|
||||||
|
color: #1BB0CE !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Client Portal Overrides
|
||||||
|
============================================================ */
|
||||||
|
.dark.theme-pumpkin .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(25, 35%, 4%) !important;
|
||||||
|
border-right-color: hsl(25, 20%, 14%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin aside .text-muted-foreground {
|
||||||
|
color: hsl(30, 10%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin aside button:hover {
|
||||||
|
background: hsla(25, 95%, 50%, 0.08) !important;
|
||||||
|
color: hsl(0, 0%, 90%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin aside .bg-primary\/10.text-primary {
|
||||||
|
background: hsl(25, 95%, 50%) !important;
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.3), 0 0 20px hsla(25, 95%, 50%, 0.1);
|
||||||
|
border-color: hsl(25, 95%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin aside .bg-primary\/10.text-primary svg {
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin header.bg-card\/80 {
|
||||||
|
background: hsl(25, 35%, 4%) !important;
|
||||||
|
border-bottom-color: hsl(25, 20%, 14%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .bg-card {
|
||||||
|
background: hsla(25, 30%, 7%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(25, 20%, 16%, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .border-border {
|
||||||
|
border-color: hsl(25, 18%, 14%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .bg-muted {
|
||||||
|
background: hsl(25, 15%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-pumpkin .bg-primary {
|
||||||
|
background: hsl(25, 95%, 50%) !important;
|
||||||
|
box-shadow: 0 0 10px hsla(25, 95%, 50%, 0.2);
|
||||||
|
}
|
||||||
|
.dark.theme-pumpkin .bg-primary:hover {
|
||||||
|
background: hsl(25, 95%, 55%) !important;
|
||||||
|
box-shadow: 0 0 15px hsla(25, 95%, 50%, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode portal */
|
||||||
|
.light.theme-pumpkin .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(0, 0%, 100%) !important;
|
||||||
|
border-right-color: hsl(25, 15%, 82%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin header.bg-card\/80 {
|
||||||
|
background: hsl(0, 0%, 98%) !important;
|
||||||
|
border-bottom-color: hsl(25, 15%, 82%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin .bg-card {
|
||||||
|
background: hsla(0, 0%, 100%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(25, 15%, 80%, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-pumpkin .bg-primary {
|
||||||
|
background: hsl(25, 90%, 42%) !important;
|
||||||
|
}
|
||||||
|
.light.theme-pumpkin .bg-primary:hover {
|
||||||
|
background: hsl(25, 90%, 48%) !important;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "Rage",
|
||||||
|
"description": "Fiery red-orange rage theme with intense dark background and burning accents",
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "0 30% 4%",
|
||||||
|
"foreground": "20 50% 95%",
|
||||||
|
"primary": "10 100% 50%",
|
||||||
|
"secondary": "30 100% 50%",
|
||||||
|
"accent": "10 100% 50%",
|
||||||
|
"muted": "0 12% 15%",
|
||||||
|
"border": "10 30% 18%",
|
||||||
|
"ring": "10 100% 50%",
|
||||||
|
"card": "0 25% 8%",
|
||||||
|
"popover": "0 25% 8%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(10,100%,50%), hsl(30,100%,50%))",
|
||||||
|
"link": "hsl(10, 100%, 55%)",
|
||||||
|
"link-hover": "hsl(30, 100%, 55%)",
|
||||||
|
"code": "hsl(10, 100%, 55%)",
|
||||||
|
"blockquote": "hsl(20, 15%, 70%)",
|
||||||
|
"table-header": "hsl(10, 100%, 55%)",
|
||||||
|
"stat-card-value": "hsl(10, 100%, 55%)",
|
||||||
|
"badge": "hsl(10, 100%, 55%)",
|
||||||
|
"page-header-title": "hsl(10, 100%, 55%)",
|
||||||
|
"text-glow": "0 0 10px hsl(10,100%,50%), 0 0 30px hsl(10,100%,50%), 0 0 60px rgba(255,0,0,0.5)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "Deep crimson-black with red and orange radial nebula gradients",
|
||||||
|
"glow-colors": ["rgba(200, 0, 0, 0.2)", "rgba(255, 100, 0, 0.15)", "rgba(150, 0, 0, 0.1)"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "20 30% 96%",
|
||||||
|
"foreground": "10 50% 10%",
|
||||||
|
"primary": "10 90% 45%",
|
||||||
|
"secondary": "30 90% 45%",
|
||||||
|
"accent": "10 90% 45%",
|
||||||
|
"muted": "20 15% 88%",
|
||||||
|
"border": "20 15% 82%",
|
||||||
|
"ring": "10 90% 45%",
|
||||||
|
"card": "0 0% 100%",
|
||||||
|
"popover": "0 0% 100%"
|
||||||
|
},
|
||||||
|
"textColors": {
|
||||||
|
"heading": "linear-gradient(135deg, hsl(10,90%,40%), hsl(30,90%,40%))",
|
||||||
|
"link": "hsl(10, 90%, 40%)",
|
||||||
|
"link-hover": "hsl(30, 90%, 45%)",
|
||||||
|
"code": "hsl(10, 90%, 40%)",
|
||||||
|
"blockquote": "hsl(10, 10%, 45%)",
|
||||||
|
"table-header": "hsl(10, 90%, 40%)",
|
||||||
|
"stat-card-value": "hsl(10, 90%, 40%)",
|
||||||
|
"badge": "hsl(10, 90%, 40%)",
|
||||||
|
"page-header-title": "hsl(10, 90%, 40%)",
|
||||||
|
"text-glow": "0 0 8px hsl(10,90%,45%), 0 0 20px hsl(10,90%,45%), 0 0 40px rgba(200,50,0,0.3)"
|
||||||
|
},
|
||||||
|
"backgrounds": {
|
||||||
|
"body": "Warm off-white with subtle red-orange gradient washes",
|
||||||
|
"glow-colors": ["rgba(200, 50, 0, 0.04)", "rgba(255, 100, 0, 0.03)"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,16 @@
|
|||||||
/* ============================================================================
|
@import url("./cosmic-theme.css");
|
||||||
Spidey Theme — Baseline Design Tokens
|
@import url("./cyberpunk-theme.css");
|
||||||
This is the current website appearance captured as a reusable CSS theme.
|
@import url("./cyber2-theme.css");
|
||||||
============================================================================ */
|
@import url("./pumpkin-theme.css");
|
||||||
|
@import url("./bw-theme.css");
|
||||||
|
|
||||||
.theme-spidey {
|
.dark.theme-spidey {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
--card: 222.2 84% 4.9%;
|
--card: 222.2 84% 4.9%;
|
||||||
--card-foreground: 210 40% 98%;
|
--card-foreground: 210 40% 98%;
|
||||||
--popover: 222.2 84% 4.9%;
|
--popover: 222.2 84% 4.9%;
|
||||||
--popover-foreground: 210 40% 98%;
|
--popover-foreground: 210 40% 98%;
|
||||||
--primary: 0 100% 53%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
--secondary-foreground: 210 40% 98%;
|
--secondary-foreground: 210 40% 98%;
|
||||||
--muted: 217.2 32.6% 17.5%;
|
--muted: 217.2 32.6% 17.5%;
|
||||||
@@ -22,19 +21,46 @@
|
|||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--border: 217.2 32.6% 17.5%;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--input: 217.2 32.6% 17.5%;
|
||||||
--ring: 0 100% 53%;
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
--sidebar: 222.2 84% 4.9%;
|
--sidebar: 222.2 84% 4.9%;
|
||||||
--sidebar-foreground: 210 40% 98%;
|
--sidebar-foreground: 210 40% 98%;
|
||||||
--sidebar-primary: 0 100% 53%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
--sidebar-accent: 217.2 32.6% 17.5%;
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
--sidebar-accent-foreground: 210 40% 98%;
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
--sidebar-border: 217.2 32.6% 17.5%;
|
||||||
--sidebar-ring: 0 100% 53%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-spidey body::before {
|
.light.theme-spidey {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
--primary: 0 100% 45%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 0 100% 45%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--sidebar: 0 0% 100%;
|
||||||
|
--sidebar-foreground: 222.2 84% 4.9%;
|
||||||
|
--sidebar-primary: 0 100% 45%;
|
||||||
|
--sidebar-primary-foreground: 0 0% 100%;
|
||||||
|
--sidebar-accent: 210 40% 96%;
|
||||||
|
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
|
||||||
|
--sidebar-border: 214.3 31.8% 91.4%;
|
||||||
|
--sidebar-ring: 0 100% 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey body::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -67,3 +93,161 @@
|
|||||||
background-size: 200% 200%;
|
background-size: 200% 200%;
|
||||||
animation: drift 60s linear infinite;
|
animation: drift 60s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.light.theme-spidey body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
repeating-conic-gradient(
|
||||||
|
from 10deg,
|
||||||
|
transparent 0deg 10deg,
|
||||||
|
rgba(220, 38, 38, 0.03) 10deg 12deg
|
||||||
|
),
|
||||||
|
repeating-radial-gradient(
|
||||||
|
circle at 50% 50%,
|
||||||
|
transparent 0,
|
||||||
|
transparent 30px,
|
||||||
|
rgba(37, 99, 235, 0.02) 30px,
|
||||||
|
transparent 32px
|
||||||
|
),
|
||||||
|
radial-gradient(1.5px 1.5px at 10% 20%, rgba(220,38,38,0.2) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 30% 80%, rgba(37,99,235,0.2) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 50% 30%, rgba(220,38,38,0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 70% 60%, rgba(37,99,235,0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 85% 15%, rgba(220,38,38,0.2) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 20% 50%, rgba(37,99,235,0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 65% 85%, rgba(220,38,38,0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 40% 10%, rgba(37,99,235,0.2) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 90% 75%, rgba(220,38,38,0.15) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 5% 60%, rgba(37,99,235,0.2) 0%, transparent 100%),
|
||||||
|
radial-gradient(1.5px 1.5px at 55% 55%, rgba(220,38,38,0.1) 0%, transparent 100%);
|
||||||
|
background-size: 200% 200%;
|
||||||
|
animation: drift 60s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
0% { background-position: 0% 0%; }
|
||||||
|
100% { background-position: 100% 100%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 100%, 53%), hsl(0, 100%, 65%), hsl(0, 100%, 53%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(217, 91%, 60%), hsl(217, 91%, 70%), hsl(217, 91%, 60%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(0, 100%, 53%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
|
||||||
|
background: hsla(217, 91%, 60%, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(0, 100%, 53%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
|
||||||
|
color: hsl(217, 91%, 60%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(0, 100%, 53%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
|
||||||
|
background: linear-gradient(to right, transparent, hsla(217, 91%, 60%, 0.25), transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
|
||||||
|
background: linear-gradient(to right, hsl(0, 100%, 53%), hsl(217, 91%, 60%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(0, 100%, 45%), hsl(0, 100%, 55%), hsl(0, 100%, 45%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
|
||||||
|
background: linear-gradient(to right, hsl(217, 91%, 50%), hsl(217, 91%, 60%), hsl(217, 91%, 50%)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark.theme-spidey .growth-word,
|
||||||
|
.light.theme-spidey .growth-word {
|
||||||
|
-webkit-text-fill-color: #1BB0CE !important;
|
||||||
|
color: #1BB0CE !important;
|
||||||
|
background: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Client Portal Overrides — Background for portal pages
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
/* Make the outer portal container transparent so body::before background shows */
|
||||||
|
.dark.theme-spidey .min-h-screen.bg-background {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar — dark glass */
|
||||||
|
.dark.theme-spidey aside.bg-card.border-r.border-border {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-right-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar nav items — muted text */
|
||||||
|
.dark.theme-spidey aside .text-muted-foreground {
|
||||||
|
color: hsl(240, 8%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-spidey aside button:hover {
|
||||||
|
background: hsla(0, 100%, 50%, 0.08) !important;
|
||||||
|
color: hsl(0, 0%, 90%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal sidebar active nav item */
|
||||||
|
.dark.theme-spidey aside .bg-primary\/10.text-primary {
|
||||||
|
background: hsl(0, 100%, 50%) !important;
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 0, 0.3), 0 0 20px rgba(255, 0, 0, 0.1);
|
||||||
|
border-color: hsl(0, 100%, 50%) !important;
|
||||||
|
}
|
||||||
|
.dark.theme-spidey aside .bg-primary\/10.text-primary svg {
|
||||||
|
color: hsl(0, 0%, 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal header — dark glass */
|
||||||
|
.dark.theme-spidey header.bg-card\/80 {
|
||||||
|
background: hsl(240, 15%, 3%) !important;
|
||||||
|
border-bottom-color: hsl(270, 25%, 10%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal card backgrounds — semi-transparent glass */
|
||||||
|
.dark.theme-spidey .bg-card {
|
||||||
|
background: hsla(240, 15%, 7%, 0.65) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-color: hsla(270, 30%, 20%, 0.35) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal borders */
|
||||||
|
.dark.theme-spidey .border-border {
|
||||||
|
border-color: hsl(270, 25%, 12%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal input fields */
|
||||||
|
.dark.theme-spidey .bg-muted {
|
||||||
|
background: hsl(240, 12%, 9%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portal primary buttons */
|
||||||
|
.dark.theme-spidey .bg-primary {
|
||||||
|
background: hsl(0, 100%, 50%) !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.dark.theme-spidey .bg-primary:hover {
|
||||||
|
background: hsl(0, 100%, 55%) !important;
|
||||||
|
box-shadow: 0 0 15px rgba(255, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
"theme": {
|
"theme": {
|
||||||
"id": "spidey",
|
"id": "spidey",
|
||||||
"name": "Spidey",
|
"name": "Spidey",
|
||||||
"description": "Current website appearance — dark theme with red accents",
|
"description": "Dark mode with red accents and web patterns; light mode with clean red-blue accents",
|
||||||
"type": "dark",
|
"type": "dual",
|
||||||
"default": true
|
"default": true
|
||||||
},
|
},
|
||||||
|
"modes": {
|
||||||
|
"dark": {
|
||||||
"colors": {
|
"colors": {
|
||||||
"background": "hsl(222.2, 84%, 4.9%)",
|
"background": "hsl(222.2, 84%, 4.9%)",
|
||||||
"foreground": "hsl(210, 40%, 98%)",
|
"foreground": "hsl(210, 40%, 98%)",
|
||||||
@@ -34,6 +36,39 @@
|
|||||||
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
|
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
|
||||||
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
|
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
|
||||||
"sidebarRing": "hsl(0, 100%, 53%)"
|
"sidebarRing": "hsl(0, 100%, 53%)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(0, 0%, 100%)",
|
||||||
|
"foreground": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"card": "hsl(0, 0%, 100%)",
|
||||||
|
"cardForeground": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
|
"popoverForeground": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"primary": "hsl(0, 100%, 45%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(210, 40%, 96%)",
|
||||||
|
"secondaryForeground": "hsl(222.2, 47.4%, 11.2%)",
|
||||||
|
"muted": "hsl(210, 40%, 96%)",
|
||||||
|
"mutedForeground": "hsl(215.4, 16.3%, 46.9%)",
|
||||||
|
"accent": "hsl(210, 40%, 96%)",
|
||||||
|
"accentForeground": "hsl(222.2, 47.4%, 11.2%)",
|
||||||
|
"destructive": "hsl(0, 84.2%, 60.2%)",
|
||||||
|
"destructiveForeground": "hsl(210, 40%, 98%)",
|
||||||
|
"border": "hsl(214.3, 31.8%, 91.4%)",
|
||||||
|
"input": "hsl(214.3, 31.8%, 91.4%)",
|
||||||
|
"ring": "hsl(0, 100%, 45%)",
|
||||||
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarForeground": "hsl(222.2, 84%, 4.9%)",
|
||||||
|
"sidebarPrimary": "hsl(0, 100%, 45%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(210, 40%, 96%)",
|
||||||
|
"sidebarAccentForeground": "hsl(222.2, 47.4%, 11.2%)",
|
||||||
|
"sidebarBorder": "hsl(214.3, 31.8%, 91.4%)",
|
||||||
|
"sidebarRing": "hsl(0, 100%, 45%)"
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"borderRadius": "0.5rem",
|
"borderRadius": "0.5rem",
|
||||||
"typography": {
|
"typography": {
|
||||||
@@ -42,11 +77,11 @@
|
|||||||
},
|
},
|
||||||
"keyColors": {
|
"keyColors": {
|
||||||
"primary": "#FF0000",
|
"primary": "#FF0000",
|
||||||
"background": "#0a0a0f",
|
"background": "#0a0a0f (dark) / #ffffff (light)",
|
||||||
"card": "#141414",
|
"card": "#141414 (dark) / #ffffff (light)",
|
||||||
"sidebar": "#0a0a0f",
|
"sidebar": "#0a0a0f (dark) / #ffffff (light)",
|
||||||
"border": "#1a1a24",
|
"border": "#1a1a24 (dark) / #e2e4ea (light)",
|
||||||
"text": "#e8e8ef",
|
"text": "#e8e8ef (dark) / #0a0a1a (light)",
|
||||||
"mutedText": "#888888"
|
"mutedText": "#888888 (dark) / #6b7280 (light)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
/* ============================================================================
|
|
||||||
Starforce Theme — Dark cosmic with crimson accents and neon green glow
|
|
||||||
Captures the current starfield + drift + glow effects used live.
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.theme-starforce {
|
|
||||||
--background: 0 0% 10%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
--card: 0 0% 9%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
--popover: 222.2 84% 11%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
--primary: 0 100% 53%;
|
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 62.8% 30.6%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
--border: 217.2 32.6% 17.5%;
|
|
||||||
--input: 217.2 32.6% 17.5%;
|
|
||||||
--ring: 0 100% 53%;
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--sidebar: 0 0% 11%;
|
|
||||||
--sidebar-foreground: 210 40% 98%;
|
|
||||||
--sidebar-primary: 145 65% 50%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 217.2 32.6% 17.5%;
|
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
|
||||||
--sidebar-border: 217.2 32.6% 17.5%;
|
|
||||||
--sidebar-ring: 0 100% 53%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-starforce body::before {
|
|
||||||
content: "";
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
pointer-events: none;
|
|
||||||
background-image:
|
|
||||||
radial-gradient(1px 1px at 10% 20%, rgba(100,100,120,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 20% 40%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 30% 10%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 40% 60%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 50% 80%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 60% 30%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 70% 50%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 80% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 90% 15%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 15% 70%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 25% 90%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 35% 35%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 45% 15%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 55% 75%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 65% 55%, rgba(80,80,100,0.5) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 75% 25%, rgba(100,100,120,0.3) 0%, transparent 100%),
|
|
||||||
radial-gradient(1px 1px at 85% 65%, rgba(90,90,110,0.4) 0%, transparent 100%),
|
|
||||||
radial-gradient(1.5px 1.5px at 95% 45%, rgba(80,80,100,0.5) 0%, transparent 100%);
|
|
||||||
background-size: 200% 200%;
|
|
||||||
animation: drift 60s linear infinite;
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"theme": {
|
|
||||||
"id": "starforce",
|
|
||||||
"name": "Starforce",
|
|
||||||
"description": "Dark cosmic theme with crimson accents, starfield background, and neon green glow effects",
|
|
||||||
"type": "dark",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"colors": {
|
|
||||||
"background": "hsl(0, 0%, 10%)",
|
|
||||||
"foreground": "hsl(210, 40%, 98%)",
|
|
||||||
"card": "hsl(0, 0%, 9%)",
|
|
||||||
"cardForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"popover": "hsl(222.2, 84%, 11%)",
|
|
||||||
"popoverForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"primary": "hsl(0, 100%, 53%)",
|
|
||||||
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
|
|
||||||
"secondary": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"secondaryForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"muted": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
|
|
||||||
"accent": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"accentForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"destructive": "hsl(0, 62.8%, 30.6%)",
|
|
||||||
"destructiveForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"border": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"input": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"ring": "hsl(0, 100%, 53%)",
|
|
||||||
"sidebar": "hsl(0, 0%, 11%)",
|
|
||||||
"sidebarForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"sidebarPrimary": "hsl(145, 65%, 50%)",
|
|
||||||
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
|
||||||
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
|
|
||||||
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
|
|
||||||
"sidebarRing": "hsl(0, 100%, 53%)"
|
|
||||||
},
|
|
||||||
"borderRadius": "0.5rem",
|
|
||||||
"typography": {
|
|
||||||
"fontFamily": "Inter, sans-serif",
|
|
||||||
"headingFont": "Inter, sans-serif"
|
|
||||||
},
|
|
||||||
"keyColors": {
|
|
||||||
"primary": "#FF0000",
|
|
||||||
"background": "#1a1a1a",
|
|
||||||
"card": "#171717",
|
|
||||||
"sidebar": "#1c1c1c",
|
|
||||||
"border": "#2a2a35",
|
|
||||||
"text": "#e8e8ef",
|
|
||||||
"mutedText": "#888888",
|
|
||||||
"glow": "#39ff14"
|
|
||||||
},
|
|
||||||
"effects": {
|
|
||||||
"starfield": true,
|
|
||||||
"starTwinkle": true,
|
|
||||||
"starDrift": true,
|
|
||||||
"textGlow": "#39ff14"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"theme": {
|
||||||
|
"id": "cyberpunk",
|
||||||
|
"name": "Cyberpunk Theme",
|
||||||
|
"description": "Complete cyberpunk transformation: semi-transparent metric cards with 2px glowing hot-pink borders, fixed cyberpunk cityscape background, neon-pink avatar badges, and vibrant chart legends with hot-pink/cyan neon swatches.",
|
||||||
|
"type": "dual",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"modes": {
|
||||||
|
"dark": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(240, 18%, 4%)",
|
||||||
|
"foreground": "hsl(210, 40%, 93%)",
|
||||||
|
"card": "hsl(240, 15%, 7%)",
|
||||||
|
"cardForeground": "hsl(210, 40%, 93%)",
|
||||||
|
"popover": "hsl(240, 15%, 7%)",
|
||||||
|
"popoverForeground": "hsl(210, 40%, 93%)",
|
||||||
|
"primary": "hsl(330, 100%, 50%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(180, 100%, 50%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"muted": "hsl(240, 12%, 12%)",
|
||||||
|
"mutedForeground": "hsl(240, 8%, 55%)",
|
||||||
|
"accent": "hsl(260, 80%, 60%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"destructive": "hsl(0, 84%, 55%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(270, 30%, 15%)",
|
||||||
|
"input": "hsl(240, 12%, 12%)",
|
||||||
|
"ring": "hsl(330, 100%, 50%)",
|
||||||
|
"sidebar": "hsl(240, 15%, 3%)",
|
||||||
|
"sidebarForeground": "hsl(210, 40%, 93%)",
|
||||||
|
"sidebarPrimary": "hsl(330, 100%, 50%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(240, 10%, 10%)",
|
||||||
|
"sidebarAccentForeground": "hsl(210, 40%, 93%)",
|
||||||
|
"sidebarBorder": "hsl(270, 25%, 12%)",
|
||||||
|
"sidebarRing": "hsl(330, 100%, 50%)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"colors": {
|
||||||
|
"background": "hsl(0, 0%, 98%)",
|
||||||
|
"foreground": "hsl(240, 10%, 15%)",
|
||||||
|
"card": "hsl(0, 0%, 100%)",
|
||||||
|
"cardForeground": "hsl(240, 10%, 15%)",
|
||||||
|
"popover": "hsl(0, 0%, 100%)",
|
||||||
|
"popoverForeground": "hsl(240, 10%, 15%)",
|
||||||
|
"primary": "hsl(330, 100%, 50%)",
|
||||||
|
"primaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"secondary": "hsl(180, 100%, 50%)",
|
||||||
|
"secondaryForeground": "hsl(0, 0%, 0%)",
|
||||||
|
"muted": "hsl(240, 5%, 94%)",
|
||||||
|
"mutedForeground": "hsl(240, 4%, 48%)",
|
||||||
|
"accent": "hsl(260, 80%, 60%)",
|
||||||
|
"accentForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"destructive": "hsl(0, 84%, 55%)",
|
||||||
|
"destructiveForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"border": "hsl(270, 10%, 88%)",
|
||||||
|
"input": "hsl(240, 5%, 92%)",
|
||||||
|
"ring": "hsl(330, 100%, 50%)",
|
||||||
|
"sidebar": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarForeground": "hsl(240, 10%, 15%)",
|
||||||
|
"sidebarPrimary": "hsl(330, 100%, 50%)",
|
||||||
|
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
|
||||||
|
"sidebarAccent": "hsl(240, 5%, 94%)",
|
||||||
|
"sidebarAccentForeground": "hsl(240, 10%, 15%)",
|
||||||
|
"sidebarBorder": "hsl(270, 10%, 88%)",
|
||||||
|
"sidebarRing": "hsl(330, 100%, 50%)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"borderRadius": "0.5rem",
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, sans-serif",
|
||||||
|
"headingFont": "Inter, sans-serif"
|
||||||
|
},
|
||||||
|
"keyColors": {
|
||||||
|
"primary": "#ff007f",
|
||||||
|
"background": "#0a0a10 (dark) / #f0f2f8 (light)",
|
||||||
|
"card": "#0f0f18 (dark) / #ffffff (light)",
|
||||||
|
"sidebar": "#07070d (dark) / #ffffff (light)",
|
||||||
|
"border": "#1c1430 (dark) / #d4d0e0 (light)",
|
||||||
|
"text": "#e8e8f0 (dark) / #1a1a2e (light)",
|
||||||
|
"mutedText": "#7a7a9a (dark) / #6b7280 (light)"
|
||||||
|
},
|
||||||
|
"neon": {
|
||||||
|
"pink": "#ff007f",
|
||||||
|
"cyan": "#00f0ff",
|
||||||
|
"violet": "#8b5cf6",
|
||||||
|
"yellow": "#ffd700",
|
||||||
|
"acidGreen": "#39ff14",
|
||||||
|
"orange": "#ff8c00"
|
||||||
|
},
|
||||||
|
"chartSegments": {
|
||||||
|
"open": { "label": "Open", "color": "#BD00FF", "description": "Neon Purple — 35%" },
|
||||||
|
"contacted": { "label": "Contacted", "color": "#00E5FF", "description": "Cyberpunk Cyan — 25%" },
|
||||||
|
"pending": { "label": "Pending", "color": "#FFD700", "description": "Electric Yellow/Orange — 17%" },
|
||||||
|
"closed": { "label": "Closed", "color": "#39FF14", "description": "Glowing Lime Green — 23%" },
|
||||||
|
"ignored": { "label": "Ignored", "color": "#FF0055", "description": "Deep Neon Crimson — 0%" }
|
||||||
|
},
|
||||||
|
"barChart": {
|
||||||
|
"newLeads": { "color": "#FF1493", "glow": "rgba(255, 0, 127, 0.5)" },
|
||||||
|
"closed": { "color": "#00F0FF", "glow": "rgba(0, 229, 255, 0.4)" }
|
||||||
|
},
|
||||||
|
"statusBadges": {
|
||||||
|
"open": { "color": "#00E5FF", "label": "Cyan Neon" },
|
||||||
|
"contacted": { "color": "#BD00FF", "label": "Purple Magenta" },
|
||||||
|
"pending": { "color": "#FFAA00", "label": "Orange Yellow" },
|
||||||
|
"closed": { "color": "#C81E3C", "label": "Crimson Red" },
|
||||||
|
"ignored": { "color": "#666666", "label": "Muted Grey" }
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
-46
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||||
ArrowUpRight, Zap, StickyNote,
|
ArrowUpRight, Zap, StickyNote, Globe,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||||
meeting: Users,
|
|
||||||
call: Phone,
|
call: Phone,
|
||||||
chat: Video,
|
|
||||||
follow_up: Clock,
|
follow_up: Clock,
|
||||||
demo: Calendar,
|
website_creation: Globe,
|
||||||
other: Calendar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventVars(type: EventType) {
|
function eventVars(type: EventType) {
|
||||||
@@ -85,14 +82,20 @@ export default function CalendarPage() {
|
|||||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
|
||||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
|
||||||
|
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
|
||||||
const [formTitle, setFormTitle] = useState("")
|
const [formTitle, setFormTitle] = useState("")
|
||||||
const [formDescription, setFormDescription] = useState("")
|
const [formDescription, setFormDescription] = useState("")
|
||||||
const [formType, setFormType] = useState<EventType>("meeting")
|
const [formType, setFormType] = useState<EventType>("website_creation")
|
||||||
const [formDate, setFormDate] = useState("")
|
const [formDate, setFormDate] = useState("")
|
||||||
const [formTime, setFormTime] = useState("")
|
const [formTime, setFormTime] = useState("")
|
||||||
const [formDuration, setFormDuration] = useState("30")
|
const [formDuration, setFormDuration] = useState("30")
|
||||||
const [formParticipantId, setFormParticipantId] = useState("")
|
const [formParticipantId, setFormParticipantId] = useState("")
|
||||||
|
const [formDeveloperId, setFormDeveloperId] = useState("")
|
||||||
|
const [formLeadId, setFormLeadId] = useState("")
|
||||||
|
const [formClientName, setFormClientName] = useState("")
|
||||||
|
const [formClientEmail, setFormClientEmail] = useState("")
|
||||||
|
const [formClientPhone, setFormClientPhone] = useState("")
|
||||||
const [formSaving, setFormSaving] = useState(false)
|
const [formSaving, setFormSaving] = useState(false)
|
||||||
const [editNotes, setEditNotes] = useState(false)
|
const [editNotes, setEditNotes] = useState(false)
|
||||||
const [notesText, setNotesText] = useState("")
|
const [notesText, setNotesText] = useState("")
|
||||||
@@ -124,6 +127,10 @@ export default function CalendarPage() {
|
|||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users || []))
|
.then((data) => setUsers(data.users || []))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
fetch("/api/leads?limit=200")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
|
||||||
|
.catch(() => {})
|
||||||
}, [user, router])
|
}, [user, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -158,7 +165,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const getEventsForDay = useCallback((day: number) => {
|
const getEventsForDay = useCallback((day: number) => {
|
||||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
|
||||||
}, [events, currentYear, currentMonth])
|
}, [events, currentYear, currentMonth])
|
||||||
|
|
||||||
const isToday = (day: number) => {
|
const isToday = (day: number) => {
|
||||||
@@ -169,9 +176,14 @@ export default function CalendarPage() {
|
|||||||
setEditingEvent(null)
|
setEditingEvent(null)
|
||||||
setFormTitle("")
|
setFormTitle("")
|
||||||
setFormDescription("")
|
setFormDescription("")
|
||||||
setFormType("meeting")
|
setFormType("website_creation")
|
||||||
setFormDuration("30")
|
setFormDuration("30")
|
||||||
setFormParticipantId("")
|
setFormParticipantId("")
|
||||||
|
setFormDeveloperId("")
|
||||||
|
setFormLeadId("")
|
||||||
|
setFormClientName("")
|
||||||
|
setFormClientEmail("")
|
||||||
|
setFormClientPhone("")
|
||||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||||
setFormDate(d.toISOString().split("T")[0])
|
setFormDate(d.toISOString().split("T")[0])
|
||||||
setFormTime("10:00")
|
setFormTime("10:00")
|
||||||
@@ -184,9 +196,14 @@ export default function CalendarPage() {
|
|||||||
setFormDescription(event.description || "")
|
setFormDescription(event.description || "")
|
||||||
setFormType(event.eventType)
|
setFormType(event.eventType)
|
||||||
setFormDuration(String(event.durationMinutes || 30))
|
setFormDuration(String(event.durationMinutes || 30))
|
||||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
|
||||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
|
||||||
setFormParticipantId(event.participantId || "")
|
setFormParticipantId(event.participantId || "")
|
||||||
|
setFormDeveloperId(event.developerId || "")
|
||||||
|
setFormLeadId(event.leadId || "")
|
||||||
|
setFormClientName(event.clientName || "")
|
||||||
|
setFormClientEmail(event.clientEmail || "")
|
||||||
|
setFormClientPhone(event.clientPhone || "")
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,23 +215,35 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
setFormSaving(true)
|
setFormSaving(true)
|
||||||
try {
|
try {
|
||||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
const body: Record<string, unknown> = {
|
||||||
|
title: formTitle.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
eventType: formType,
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime: string | undefined
|
||||||
|
if (formType !== "website_creation") {
|
||||||
|
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||||
let endTime = null
|
let endTime = null
|
||||||
if (formDuration) {
|
if (formDuration) {
|
||||||
const end = new Date(startTime)
|
const end = new Date(startTime)
|
||||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||||
endTime = end.toISOString()
|
endTime = end.toISOString()
|
||||||
}
|
}
|
||||||
|
body.startTime = startTime
|
||||||
const body: Record<string, unknown> = {
|
body.endTime = endTime
|
||||||
title: formTitle.trim(),
|
body.durationMinutes = formDuration ? parseInt(formDuration) : null
|
||||||
description: formDescription.trim() || null,
|
} else if (formDate) {
|
||||||
eventType: formType,
|
startTime = new Date(`${formDate}T00:00:00`).toISOString()
|
||||||
startTime,
|
body.startTime = startTime
|
||||||
endTime,
|
|
||||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formClientName) body.clientName = formClientName
|
||||||
|
if (formClientEmail) body.clientEmail = formClientEmail
|
||||||
|
if (formClientPhone) body.clientPhone = formClientPhone
|
||||||
if (formParticipantId) body.participantId = formParticipantId
|
if (formParticipantId) body.participantId = formParticipantId
|
||||||
|
if (formDeveloperId) body.developerId = formDeveloperId
|
||||||
|
if (formLeadId) body.leadId = formLeadId
|
||||||
|
|
||||||
let res
|
let res
|
||||||
if (editingEvent) {
|
if (editingEvent) {
|
||||||
@@ -235,11 +264,13 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (editingEvent) {
|
if (editingEvent) {
|
||||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
toast.success("Event updated")
|
toast.success("Event updated")
|
||||||
} else {
|
} else {
|
||||||
setEvents((prev) => [...prev, data.event])
|
setEvents((prev) => [...prev, data.event])
|
||||||
|
if (startTime) {
|
||||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||||
|
}
|
||||||
toast.success("Event created")
|
toast.success("Event created")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +291,7 @@ export default function CalendarPage() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Failed to update")
|
if (!res.ok) throw new Error("Failed to update")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
toast.success(`Event ${newStatus}`)
|
toast.success(`Event ${newStatus}`)
|
||||||
setDetailEvent(null)
|
setDetailEvent(null)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -446,6 +477,11 @@ export default function CalendarPage() {
|
|||||||
>
|
>
|
||||||
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
<Icon className="h-2 w-2 shrink-0 opacity-50" />
|
||||||
<span className="truncate font-medium">{event.title}</span>
|
<span className="truncate font-medium">{event.title}</span>
|
||||||
|
{event.lead && (
|
||||||
|
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
|
||||||
|
{event.lead.contactName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -464,6 +500,15 @@ export default function CalendarPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Unscheduled projects bar */}
|
||||||
|
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
|
||||||
|
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
|
||||||
|
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
|
||||||
|
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -534,12 +579,19 @@ export default function CalendarPage() {
|
|||||||
{event.status}
|
{event.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{event.startTime ? (
|
||||||
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{formatDate(event.startTime)}
|
{formatDate(event.startTime)}
|
||||||
<span className="text-muted-foreground/20">·</span>
|
<span className="text-muted-foreground/20">·</span>
|
||||||
{formatTime(event.startTime)}
|
{formatTime(event.startTime)}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
Website project — no schedule
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{event.durationMinutes && (
|
{event.durationMinutes && (
|
||||||
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
|
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
|
||||||
<Zap className="h-2.5 w-2.5" />
|
<Zap className="h-2.5 w-2.5" />
|
||||||
@@ -547,21 +599,20 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<div className="flex items-center gap-1 mt-1">
|
||||||
<span className={cn(
|
{event.developerId === user?.id ? (
|
||||||
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
|
<>
|
||||||
event.userId === user?.id
|
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
|
||||||
? "bg-primary/10 text-primary ring-primary/20"
|
{event.creator?.name.charAt(0) || "?"}
|
||||||
: "bg-muted text-muted-foreground/60 ring-border",
|
|
||||||
)}>
|
|
||||||
{event.userId === user?.id
|
|
||||||
? (event.participant?.name.charAt(0) || "?")
|
|
||||||
: (event.creator?.name.charAt(0) || "?")}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||||
{event.userId === user?.id
|
Assigned by {event.creator?.name || "Unknown"}
|
||||||
? (event.participant?.name || "No participant")
|
|
||||||
: (event.creator?.name || "Unknown")}
|
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
|
) : event.lead ? (
|
||||||
|
<span className="text-[10px] text-muted-foreground/60 font-medium">
|
||||||
|
{event.lead.contactName}{event.lead.companyName ? ` — ${event.lead.companyName}` : ""}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -579,7 +630,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[520px]">
|
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -608,11 +659,28 @@ export default function CalendarPage() {
|
|||||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{formType === "website_creation" && (
|
||||||
|
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
|
||||||
|
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
|
||||||
|
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
|
||||||
|
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||||
@@ -621,19 +689,16 @@ export default function CalendarPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="meeting">Meeting</SelectItem>
|
<SelectItem value="website_creation">Website Creation</SelectItem>
|
||||||
<SelectItem value="call">Call</SelectItem>
|
<SelectItem value="call">Call</SelectItem>
|
||||||
<SelectItem value="chat">Video Chat</SelectItem>
|
|
||||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||||
<SelectItem value="demo">Demo</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||||
<SelectTrigger id="duration" className="h-10">
|
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -664,8 +729,40 @@ export default function CalendarPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
|
||||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger id="developer" className="h-10">
|
||||||
|
<SelectValue placeholder="Select a developer…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name} {u.role ? `(${u.role})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
|
||||||
|
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger id="lead" className="h-10">
|
||||||
|
<SelectValue placeholder="Select a client lead…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{leads.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.contactName}{l.companyName ? ` — ${l.companyName}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
|
||||||
|
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
<DialogFooter className="flex items-center justify-between border-t pt-4">
|
||||||
@@ -690,7 +787,7 @@ export default function CalendarPage() {
|
|||||||
{/* Event Detail Dialog */}
|
{/* Event Detail Dialog */}
|
||||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||||
{detailEvent && (
|
{detailEvent && (
|
||||||
<DialogContent className="sm:max-w-[460px]">
|
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||||
@@ -711,6 +808,7 @@ export default function CalendarPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Date / Time */}
|
{/* Date / Time */}
|
||||||
|
{detailEvent.startTime ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||||
@@ -727,6 +825,12 @@ export default function CalendarPage() {
|
|||||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
|
||||||
|
<p className="text-sm text-muted-foreground/50 italic">No scheduled time — website creation project</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Duration + Type */}
|
{/* Duration + Type */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -751,11 +855,26 @@ export default function CalendarPage() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
{detailEvent.description && (
|
{detailEvent.description && (
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Client details */}
|
||||||
|
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Client Details
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
|
||||||
|
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
|
||||||
|
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Linked lead */}
|
{/* Linked lead */}
|
||||||
{detailEvent.lead && (
|
{detailEvent.lead && (
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
@@ -802,6 +921,32 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Developer */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
|
||||||
|
</p>
|
||||||
|
{detailEvent.developer ? (
|
||||||
|
<p className="text-sm font-bold flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
|
||||||
|
{detailEvent.developer.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{detailEvent.developer.name}</span>
|
||||||
|
{detailEvent.developer.role && (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
|
||||||
|
)}
|
||||||
|
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
|
||||||
|
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
|
||||||
|
Assigned by {detailEvent.creator?.name || "Unknown"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Participant Notes (editable by both parties) */}
|
{/* Participant Notes (editable by both parties) */}
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
@@ -836,9 +981,15 @@ export default function CalendarPage() {
|
|||||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||||
{detailEvent.status === "scheduled" && (
|
{detailEvent.status === "scheduled" && (
|
||||||
<>
|
<>
|
||||||
|
{detailEvent.developerId === user?.id ? (
|
||||||
|
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
|
export type EventType = "call" | "follow_up" | "website_creation"
|
||||||
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
|
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
|
||||||
|
|
||||||
export interface CalendarEvent {
|
export interface CalendarEvent {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
participantId: string | null
|
participantId: string | null
|
||||||
|
developerId: string | null
|
||||||
leadId: string | null
|
leadId: string | null
|
||||||
conversationId: string | null
|
conversationId: string | null
|
||||||
title: string
|
title: string
|
||||||
@@ -17,6 +18,10 @@ export interface CalendarEvent {
|
|||||||
status: EventStatus
|
status: EventStatus
|
||||||
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||||
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||||
|
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
|
||||||
lead: { id: string; companyName: string; contactName: string } | null
|
lead: { id: string; companyName: string; contactName: string } | null
|
||||||
|
clientName: string | null
|
||||||
|
clientEmail: string | null
|
||||||
|
clientPhone: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-20
@@ -202,7 +202,8 @@ Provide concise, actionable sales advice. When asked about a specific job catego
|
|||||||
{ role: "user", content: userMessage },
|
{ role: "user", content: userMessage },
|
||||||
],
|
],
|
||||||
stream: false,
|
stream: false,
|
||||||
options: { temperature: 0.7, num_predict: 1024 },
|
keep_alive: "30m",
|
||||||
|
options: { temperature: 0.7, num_predict: 2048, num_thread: 12 },
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -268,6 +269,13 @@ function parseBody(req) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkPort(http, host, port, timeout) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const r = http.get(`http://${host}:${port}`, { timeout }, (res) => { res.resume(); resolve() })
|
||||||
|
r.on("error", reject)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function parseURL(req) {
|
function parseURL(req) {
|
||||||
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`)
|
||||||
return { pathname: url.pathname, searchParams: url.searchParams }
|
return { pathname: url.pathname, searchParams: url.searchParams }
|
||||||
@@ -308,27 +316,23 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /status — combined health of all services
|
// GET /status — combined health of all services
|
||||||
// Used by the splash page to check if AI, Scraper, and Frontend are ready.
|
// Used by the splash page to check if AI and Frontend are ready.
|
||||||
// Polls each service internally to avoid cross-origin CORS issues.
|
// Scraper is no longer part of dev:start so it's always reported ready.
|
||||||
if (req.method === "GET" && pathname === "/status") {
|
if (req.method === "GET" && pathname === "/status") {
|
||||||
const { default: http } = await import("http")
|
const { default: http } = await import("http")
|
||||||
const results = { ai: true }
|
const results = { ai: true, scraper: true }
|
||||||
// Check scraper (port 3008)
|
// Check frontend (port 3006) — try localhost, fallback to 127.0.0.1
|
||||||
try {
|
try {
|
||||||
await new Promise((resolve, reject) => {
|
await checkPort(http, "localhost", 3006, 2000)
|
||||||
const r = http.get("http://127.0.0.1:3008/health", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
|
||||||
r.on("error", reject)
|
|
||||||
})
|
|
||||||
results.scraper = true
|
|
||||||
} catch { results.scraper = false }
|
|
||||||
// Check frontend (port 3006)
|
|
||||||
try {
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
const r = http.get("http://127.0.0.1:3006", { timeout: 3000 }, (res) => { res.resume(); resolve() })
|
|
||||||
r.on("error", reject)
|
|
||||||
})
|
|
||||||
results.frontend = true
|
results.frontend = true
|
||||||
} catch { results.frontend = false }
|
} catch {
|
||||||
|
try {
|
||||||
|
await checkPort(http, "127.0.0.1", 3006, 2000)
|
||||||
|
results.frontend = true
|
||||||
|
} catch {
|
||||||
|
results.frontend = false
|
||||||
|
}
|
||||||
|
}
|
||||||
return sendJSON(res, 200, results)
|
return sendJSON(res, 200, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,9 +370,11 @@ const server = http.createServer(async (req, res) => {
|
|||||||
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
let browsers = { firefox: { path: null }, opera: { path: null }, chrome: { path: null }, edge: { path: null } }
|
||||||
let facebookLoggedIn = false
|
let facebookLoggedIn = false
|
||||||
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
let selectedBrowser = process.env.SELECTED_BROWSER || ""
|
||||||
|
let scraperAvailable = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
await fetch("http://127.0.0.1:3008/health", { signal: AbortSignal.timeout(2000) })
|
||||||
|
scraperAvailable = true
|
||||||
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
|
const profiles = await (await fetch("http://127.0.0.1:3008/setup/profile", { signal: AbortSignal.timeout(5000) })).json()
|
||||||
for (const [b, p] of Object.entries(profiles)) {
|
for (const [b, p] of Object.entries(profiles)) {
|
||||||
if (p) browsers[b] = { path: p }
|
if (p) browsers[b] = { path: p }
|
||||||
@@ -395,8 +401,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const anyDetected = Object.values(browsers).some(v => v.path)
|
const anyDetected = Object.values(browsers).some(v => v.path)
|
||||||
// first_run = any setup step is incomplete
|
// first_run = env or Ollama incomplete, or scraper is available but detection incomplete
|
||||||
const firstRun = !envExists || !ollamaRunning || !anyDetected || !facebookLoggedIn || !modelAvailable
|
const firstRun = !envExists || !ollamaRunning || (scraperAvailable && (!anyDetected || !facebookLoggedIn))
|
||||||
|
|
||||||
return sendJSON(res, 200, {
|
return sendJSON(res, 200, {
|
||||||
first_run: firstRun,
|
first_run: firstRun,
|
||||||
@@ -576,11 +582,40 @@ async function processRequest(req, res, body, startTime) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── Warm up model on startup ────────────────────────────────────
|
||||||
|
// Pre-loads the model into Ollama's memory so the first user
|
||||||
|
// request doesn't pay the cold-start penalty.
|
||||||
|
async function warmModel() {
|
||||||
|
console.log(`Warming up model ${MODEL}...`)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
signal: AbortSignal.timeout(300000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
prompt: "Hello",
|
||||||
|
keep_alive: "30m",
|
||||||
|
options: { num_predict: 1, num_thread: 12 },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
console.log(`Model ${MODEL} loaded and kept alive for 30 minutes`)
|
||||||
|
} else {
|
||||||
|
console.warn(`Model warm-up returned ${res.status}`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Model warm-up failed (will load on first request): ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Start ───────────────────────────────────────────────────────
|
// ── Start ───────────────────────────────────────────────────────
|
||||||
server.listen(PORT, HOST, () => {
|
server.listen(PORT, HOST, () => {
|
||||||
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
console.log(`CRM AI server listening on http://${HOST}:${PORT}`)
|
||||||
console.log(` Model: ${MODEL}`)
|
console.log(` Model: ${MODEL}`)
|
||||||
console.log(` Ollama: ${OLLAMA_URL}`)
|
console.log(` Ollama: ${OLLAMA_URL}`)
|
||||||
|
// Kick off warm-up in background (don't block server start)
|
||||||
|
warmModel()
|
||||||
})
|
})
|
||||||
|
|
||||||
initPg()
|
initPg()
|
||||||
|
|||||||
+526
-105
@@ -341,6 +341,7 @@ OFFER_PATTERNS = [
|
|||||||
r"\bfree\s+domain\b",
|
r"\bfree\s+domain\b",
|
||||||
r"\bweb\s+hosting\b",
|
r"\bweb\s+hosting\b",
|
||||||
r"\bfree\s+website\b",
|
r"\bfree\s+website\b",
|
||||||
|
r"\bfree\s+\w+\s+website\b",
|
||||||
r"\bprofessional\s+website",
|
r"\bprofessional\s+website",
|
||||||
r"\baffordable\s+web\b",
|
r"\baffordable\s+web\b",
|
||||||
r"\bget\s+a\s+free\b",
|
r"\bget\s+a\s+free\b",
|
||||||
@@ -365,6 +366,88 @@ OFFER_PATTERNS = [
|
|||||||
r"\bsponsored\b",
|
r"\bsponsored\b",
|
||||||
r"\bpromoted\b",
|
r"\bpromoted\b",
|
||||||
r"\badvertisement\b",
|
r"\badvertisement\b",
|
||||||
|
r"\bdo\s+you\s+(need|want)\b",
|
||||||
|
r"\bwe\s+(can|will)\s+(build|design|create|develop|do)\b",
|
||||||
|
r"\bi\s+(can|will)\s+(build|design|create|develop|do)\b",
|
||||||
|
r"\bmy\s+portfolio\b",
|
||||||
|
r"\breasonable\s+(price|pricing|rate)\b",
|
||||||
|
r"\bwhatsapp\s+(me|us|at)\b",
|
||||||
|
r"\bwhatsapp\b",
|
||||||
|
r"\blooking\s+for\s+(a\s+)?(business|client|customer)\b",
|
||||||
|
r"\bhelp\s+your\s+business\b",
|
||||||
|
r"\bi\s+am\s+a\s+(web|freelance|designer|developer)\b",
|
||||||
|
r"\bcontact\s+me\b",
|
||||||
|
r"\bwe\s+(are|offer|provide)\s+(web|website|design|service)\b",
|
||||||
|
r"\btake\s+(the\s+|this\s+)?quiz\b",
|
||||||
|
r"\bhomeschool\b",
|
||||||
|
r"\byour\s+(home\s+)?tutor\b",
|
||||||
|
r"\blink\s+(in\s+)?(bio|comment|below)\b",
|
||||||
|
r"\bapply\s+now\b",
|
||||||
|
r"\bget\s+started\b",
|
||||||
|
r"\bfor\s+only\b",
|
||||||
|
r"\blow\s+(price|cost|rate)\b",
|
||||||
|
r"\bhit\s+me\s+up\b",
|
||||||
|
r"\bsend\s+me\s+(a\s+)?(message|dm|pm)\b",
|
||||||
|
r"\bi\s+do\s+(web|website|design|development)\b",
|
||||||
|
r"\bwe\s+do\s+(web|website|design|development)\b",
|
||||||
|
r"\bi'm\s+offering\b",
|
||||||
|
r"\bwe're\s+offering\b",
|
||||||
|
r"\bstarting\s+a\s+\w+\s+(service|business)\b",
|
||||||
|
r"\blooking\s+for\s+a\s+few\s+businesses?\b",
|
||||||
|
r"\blet('?)s\s+connect\b",
|
||||||
|
r"\bi\s+have\s+projects?\b",
|
||||||
|
r"\byour\s+business\b",
|
||||||
|
r"\bwordpress\s+development\b",
|
||||||
|
r"\be.?commerce\s+website\b",
|
||||||
|
r"\bwebsite\s+builder\b",
|
||||||
|
r"\bai\s+studio\b",
|
||||||
|
r"\bpage\s+speed\b",
|
||||||
|
r"\bguest\s+post",
|
||||||
|
r"\bguest\s+blog",
|
||||||
|
r"\bfor\s+sale\b",
|
||||||
|
r"\bselling\s+(my|a|the|this)\b",
|
||||||
|
r"\bpremium\b",
|
||||||
|
r"\bi'm\s+selling\b",
|
||||||
|
r"\bgroup\b",
|
||||||
|
r"\bi\s+can\s+help\b",
|
||||||
|
r"\binbox\s+me\b",
|
||||||
|
r"\bpm\s+me\b",
|
||||||
|
r"\bdm\s+me\b",
|
||||||
|
r"\bmessage\s+me\s+for\b",
|
||||||
|
r"\bquote\b",
|
||||||
|
r"\bdiscount\b",
|
||||||
|
r"\bbest\s+(deal|price|offer|service)\b",
|
||||||
|
r"\bprice\s+(start|begin|include|range)\b",
|
||||||
|
r"\breach\s+out\b",
|
||||||
|
r"\bclick\s+(the\s+)?link\b",
|
||||||
|
r"\bcheck\s+(out|this|my)\b",
|
||||||
|
r"\bwebsite\s+for\s+your\b",
|
||||||
|
r"\bprobono\b",
|
||||||
|
r"\bfreelance\s+opportunit",
|
||||||
|
r"\bfull.?stack\b",
|
||||||
|
r"\bresponsive\s+(web)?sites?\b",
|
||||||
|
r"\blooking\s+for\s+(new\s+)?(projects?|work)\b",
|
||||||
|
r"\bmern\s+stack\b",
|
||||||
|
r"\bexpress\s+js\b",
|
||||||
|
r"\breact\s+js\b",
|
||||||
|
r"\bnode\s+js\b",
|
||||||
|
r"\bwebsite\s+for\s+\$\b",
|
||||||
|
r"\bi\s+build\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bwe\s+build\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bi\s+create\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bwe\s+create\s+(websites?|shopify|wordpress)\b",
|
||||||
|
r"\bfull.?time\s+(position|job|role|work)\b",
|
||||||
|
r"\bremote\s+(position|job|role|work)\b",
|
||||||
|
r"\bhelp\s+wanted\b",
|
||||||
|
r"\bjob\s+(opening|vacancy|opportunity)\b",
|
||||||
|
r"\bnow\s+hiring\b",
|
||||||
|
r"\bpart.?time\s+(position|job|role|work)\b",
|
||||||
|
r"\byears?\s+of\s+(teaching|experience)\b",
|
||||||
|
r"\bsend\s+(you|me)\s+(the\s+)?link\b",
|
||||||
|
r"\bcomment\s+.*\bsend\b",
|
||||||
|
r"\bfor\s+free\b",
|
||||||
|
r"\bmake\s+money\b",
|
||||||
|
r"\bno\s+coding\b",
|
||||||
]
|
]
|
||||||
|
|
||||||
REQUEST_PATTERNS = [
|
REQUEST_PATTERNS = [
|
||||||
@@ -513,28 +596,84 @@ def is_offer(text: str) -> bool:
|
|||||||
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
# Each run picks 2-4 random queries to vary behavior and reduce detection.
|
||||||
FB_SEARCHES = [
|
FB_SEARCHES = [
|
||||||
"looking for web developer",
|
"looking for web developer",
|
||||||
"need a website designed",
|
|
||||||
"need website built South Africa",
|
|
||||||
"need someone to build my website",
|
"need someone to build my website",
|
||||||
"need web designer",
|
|
||||||
"looking for someone to create website",
|
|
||||||
"need ecommerce website built",
|
|
||||||
"want to hire web developer",
|
|
||||||
"need wordpress website",
|
|
||||||
"I need a website for my business",
|
|
||||||
"need a site for my business",
|
|
||||||
"looking for website designer South Africa",
|
|
||||||
"need website for my small business",
|
"need website for my small business",
|
||||||
"who can build me a website",
|
"who can build me a website",
|
||||||
"want to create a website for my business",
|
"recommend a web developer",
|
||||||
"looking for affordable web design",
|
"I need a website for my",
|
||||||
"need a web developer South Africa",
|
"need a site for my business",
|
||||||
|
"looking for web designer",
|
||||||
"help me build a website",
|
"help me build a website",
|
||||||
"need someone to design my website",
|
"need someone to design my website",
|
||||||
"looking for web designer near me",
|
"anyone know a web developer",
|
||||||
"need a website for my startup",
|
"need a web developer for my project",
|
||||||
|
"need website for my startup",
|
||||||
|
"want a website for my company",
|
||||||
|
"need a new website for my",
|
||||||
|
"looking for web developer for my business",
|
||||||
|
"need ecommerce website for my",
|
||||||
|
"looking for affordable web design",
|
||||||
|
"need wordpress website",
|
||||||
|
"who can design a website for my",
|
||||||
|
"looking for someone to create a website",
|
||||||
|
"recommendations for a web designer",
|
||||||
|
"I need a website",
|
||||||
|
"I need a website built",
|
||||||
|
"need wordpress site",
|
||||||
|
"need a custom website",
|
||||||
|
"I need a new website",
|
||||||
|
"need an online store",
|
||||||
|
"need an online shop",
|
||||||
|
"need website help",
|
||||||
|
"who can help me with my website",
|
||||||
|
"looking for a website designer",
|
||||||
|
"need a shopify website",
|
||||||
|
"looking for website design",
|
||||||
|
"want to build a website for my",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
TUTORING_SEARCHES = [
|
||||||
|
"need a tutor",
|
||||||
|
"looking for a tutor",
|
||||||
|
"tutor for my child",
|
||||||
|
"need help with homework",
|
||||||
|
"private tutor needed",
|
||||||
|
"looking for tutoring",
|
||||||
|
"need a math tutor",
|
||||||
|
"english tutor needed",
|
||||||
|
"online tutor for",
|
||||||
|
"lessons for my child",
|
||||||
|
"help my child with",
|
||||||
|
"need someone to tutor",
|
||||||
|
"looking for someone to teach",
|
||||||
|
"tutoring for my",
|
||||||
|
"need help learning",
|
||||||
|
"recommend a tutor",
|
||||||
|
"anyone know a tutor",
|
||||||
|
"need a private tutor",
|
||||||
|
"tutoring services for my child",
|
||||||
|
"need academic help",
|
||||||
|
"need a reading tutor",
|
||||||
|
"looking for piano teacher",
|
||||||
|
"need help with maths",
|
||||||
|
"need a science tutor",
|
||||||
|
"looking for language tutor",
|
||||||
|
"need programming tutor",
|
||||||
|
"coding tutor for my",
|
||||||
|
"sat tutor needed",
|
||||||
|
"exam preparation tutor",
|
||||||
|
"need a homeschool tutor",
|
||||||
|
"tutor near me for",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _search_list_for_query(query: str) -> list[str]:
|
||||||
|
"""Pick the appropriate search query pool based on the search term."""
|
||||||
|
tl = query.lower()
|
||||||
|
tutoring_terms = ["tutor", "tutoring", "lessons", "homework", "teach", "learning", "child", "math", "english", "science", "exam", "homeschool", "coding", "programming", "piano", "reading"]
|
||||||
|
if any(t in tl for t in tutoring_terms):
|
||||||
|
return TUTORING_SEARCHES
|
||||||
|
return FB_SEARCHES
|
||||||
|
|
||||||
VIEWPORTS = [
|
VIEWPORTS = [
|
||||||
{'width': 1280, 'height': 800},
|
{'width': 1280, 'height': 800},
|
||||||
{'width': 1366, 'height': 768},
|
{'width': 1366, 'height': 768},
|
||||||
@@ -649,16 +788,31 @@ def _clean_fb_text(text: str) -> str:
|
|||||||
cleaned_lines.append(stripped)
|
cleaned_lines.append(stripped)
|
||||||
return '\n'.join(cleaned_lines)
|
return '\n'.join(cleaned_lines)
|
||||||
|
|
||||||
|
# Words that should never be treated as person names
|
||||||
|
_NON_NAMES = {"online","tutor","tutoring","school","academy","learning","urgently","looking","south","africa","philippines","australia","creative","professional","digital","services","solutions","statistics","actuarial","homeschooling","homeschool","reading","math","english","science","grade","group","page","website","design","development","agency","company","studio","graphic","technical","support","customer","guest","post","fashion","wordpress","shopify","ecommerce","business","marketing","social","media","content","strategy","seo","free","domain","hosting","sponsored","promoted","advertisement","need","want","help","please","contact","send","message","whatsapp","hire","freelance","writer","blogger","expert","specialist","consultant","freelancer","software","creators","village","town","city","university","college","institute","evolve","aesthetics","sale","selling","premium","builder","pro","people","ecommerce","press","anonymous","tuition","parents","tutors","advanced","custom","fields","speed","optimization","elementor","woocommerce","acf","availability","january","february","march","april","may","june","july","august","september","october","november","december","mums","dads","uae","sharjah","dubai","abu","dhabi","hong","kong","canada","usa","united","kingdom","aunty","piano","plus","recommendations","needed"}
|
||||||
|
|
||||||
# ── Post Extraction ──────────────────────────────────────────────────
|
# ── Post Extraction ──────────────────────────────────────────────────
|
||||||
# Two strategies for extracting posts from page content:
|
# Two strategies for extracting posts from page content:
|
||||||
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
|
# 1. _extract_posts_from_elements — uses structured DOM data from _get_article_elements()
|
||||||
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
|
# 2. _extract_posts_from_text — fallback that parses raw page text line by line
|
||||||
# Both apply dedup (seen_texts), offer filtering, and request scoring.
|
# Both apply dedup (seen_texts), offer filtering, and request scoring.
|
||||||
|
|
||||||
|
_GROUP_SUFFIXES = [" - South Africa", " - Australia", " - Philippines", " PH", "Group", "help group", "info group", "public group", "private group", "group for", "community", "creators", "marketplace"]
|
||||||
|
|
||||||
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[dict]:
|
||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
|
now = datetime.utcnow()
|
||||||
for el in elements:
|
for el in elements:
|
||||||
|
# Reject group posts immediately
|
||||||
|
if el.get('isGroup'):
|
||||||
|
continue
|
||||||
|
# Reject posts older than 2 days
|
||||||
|
ts = el.get('ts', 0)
|
||||||
|
if ts:
|
||||||
|
age_seconds = (now.timestamp() * 1000 - ts) / 1000
|
||||||
|
if age_seconds > 172800: # 2 days
|
||||||
|
continue
|
||||||
raw_text = el.get('text', '')
|
raw_text = el.get('text', '')
|
||||||
if len(raw_text) < 40:
|
if len(raw_text) < 40:
|
||||||
continue
|
continue
|
||||||
@@ -672,13 +826,23 @@ def _extract_posts_from_elements(elements: list[dict], base_url: str) -> list[di
|
|||||||
if dekey in seen_texts:
|
if dekey in seen_texts:
|
||||||
continue
|
continue
|
||||||
seen_texts.add(dekey)
|
seen_texts.add(dekey)
|
||||||
|
# Reject if text contains group-like patterns
|
||||||
|
lower = text.lower()
|
||||||
|
if ' / ' in lower:
|
||||||
|
continue
|
||||||
|
skip = False
|
||||||
|
for suff in _GROUP_SUFFIXES:
|
||||||
|
if suff.lower() in lower:
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
request_score = 2 if is_request(text) else 0
|
request_score = 2 if is_request(text) else 0
|
||||||
post_url = el.get('url') or base_url
|
post_url = el.get('url') or base_url
|
||||||
|
|
||||||
# Prefer JS-extracted date, fall back to text parsing
|
# Prefer JS-extracted date, fall back to text parsing
|
||||||
js_date = (el.get('date') or '').strip()
|
js_date = (el.get('date') or '').strip()
|
||||||
if js_date:
|
if js_date:
|
||||||
# Try ISO datetime from <time datetime>
|
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
dt = datetime.fromisoformat(js_date.replace('Z', '+00:00'))
|
||||||
date_str = dt.strftime('%Y-%m-%d')
|
date_str = dt.strftime('%Y-%m-%d')
|
||||||
@@ -706,6 +870,25 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
posts = []
|
posts = []
|
||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
cur = []
|
cur = []
|
||||||
|
|
||||||
|
def _find_author(candidate_lines: list[str]) -> str:
|
||||||
|
for line in reversed(candidate_lines):
|
||||||
|
ln = line.strip()
|
||||||
|
if not ln or len(ln) > 60 or len(ln) < 3:
|
||||||
|
continue
|
||||||
|
words = ln.split()
|
||||||
|
if not (1 < len(words) < 8):
|
||||||
|
continue
|
||||||
|
if not all(w[0].isalpha() and w[0].isupper() for w in words if w):
|
||||||
|
continue
|
||||||
|
lower_ln = ln.lower()
|
||||||
|
if any(kw in lower_ln for kw in BROAD_KEYWORDS) or is_offer(ln):
|
||||||
|
continue
|
||||||
|
if 'http' in ln or '/' in ln or '@' in ln:
|
||||||
|
continue
|
||||||
|
return ln
|
||||||
|
return ''
|
||||||
|
|
||||||
for l in lines:
|
for l in lines:
|
||||||
words = re.findall(r'[A-Za-z]{2,}', l)
|
words = re.findall(r'[A-Za-z]{2,}', l)
|
||||||
if len(words) < 2:
|
if len(words) < 2:
|
||||||
@@ -721,7 +904,7 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
posts.append({
|
posts.append({
|
||||||
"title": snippet[:300],
|
"title": snippet[:300],
|
||||||
"content": snippet[:1000],
|
"content": snippet[:1000],
|
||||||
"author": '',
|
"author": _find_author(cur[-3:]),
|
||||||
"url": url,
|
"url": url,
|
||||||
"source": "facebook",
|
"source": "facebook",
|
||||||
"date": _parse_fb_date(cur + [l]),
|
"date": _parse_fb_date(cur + [l]),
|
||||||
@@ -745,7 +928,61 @@ def _extract_posts_from_text(raw: str, url: str) -> list[dict]:
|
|||||||
cur.append(l)
|
cur.append(l)
|
||||||
if len(cur) > 10:
|
if len(cur) > 10:
|
||||||
cur.pop(0)
|
cur.pop(0)
|
||||||
return posts
|
# Post-process: scan each post's text for inline names
|
||||||
|
def _scan_inline_name(text: str) -> str:
|
||||||
|
for m in re.finditer(r'([A-Z][a-z]{2,}(?:\s+[A-Z][a-z]{2,}){1,3})[\s,;:]', text):
|
||||||
|
cand = m.group(1).strip()
|
||||||
|
if cand and 3 <= len(cand) <= 60:
|
||||||
|
lower = cand.lower()
|
||||||
|
words = lower.split()
|
||||||
|
if not any(kw in lower for kw in BROAD_KEYWORDS) and not is_offer(cand):
|
||||||
|
if not any(w in _NON_NAMES for w in words):
|
||||||
|
return cand
|
||||||
|
return ''
|
||||||
|
for p in posts:
|
||||||
|
if not p.get('author'):
|
||||||
|
txt = p.get('content') or p.get('title') or ''
|
||||||
|
name = _scan_inline_name(txt)
|
||||||
|
if name:
|
||||||
|
p['author'] = name
|
||||||
|
# Remove group posts: any post whose text looks like it came from a group
|
||||||
|
filtered = []
|
||||||
|
for p in posts:
|
||||||
|
t = (p.get('title') or p.get('content') or '')
|
||||||
|
if ' / ' in t:
|
||||||
|
continue
|
||||||
|
lower = t.lower()
|
||||||
|
skip = False
|
||||||
|
for suff in _GROUP_SUFFIXES:
|
||||||
|
if suff.lower() in lower:
|
||||||
|
skip = True
|
||||||
|
break
|
||||||
|
if skip:
|
||||||
|
continue
|
||||||
|
filtered.append(p)
|
||||||
|
# Dedup: merge posts where one is a suffix of another (same post split)
|
||||||
|
merged = []
|
||||||
|
for p in filtered:
|
||||||
|
t = (p.get('title') or p.get('content') or '').lower()
|
||||||
|
# Check if this post is substantially contained in an already-merged post
|
||||||
|
found = False
|
||||||
|
for i, m in enumerate(merged):
|
||||||
|
mt = (m.get('title') or m.get('content') or '').lower()
|
||||||
|
# Word-level overlap: count shared words
|
||||||
|
twords = set(t.split())
|
||||||
|
mwords = set(mt.split())
|
||||||
|
if len(twords) > 3 and len(mwords) > 3:
|
||||||
|
overlap = len(twords & mwords)
|
||||||
|
smaller = min(len(twords), len(mwords))
|
||||||
|
if overlap / smaller >= 0.6:
|
||||||
|
# Keep the longer one
|
||||||
|
if len(t) > len(mt):
|
||||||
|
merged[i] = p
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
merged.append(p)
|
||||||
|
return merged
|
||||||
|
|
||||||
# ── Human-like Behavior Simulation ──────────────────────────────────
|
# ── Human-like Behavior Simulation ──────────────────────────────────
|
||||||
# These functions add random delays, mouse movements, and scroll patterns
|
# These functions add random delays, mouse movements, and scroll patterns
|
||||||
@@ -797,58 +1034,40 @@ async def _get_article_elements(page) -> list[dict]:
|
|||||||
return await page.evaluate('''() => {
|
return await page.evaluate('''() => {
|
||||||
const results = [];
|
const results = [];
|
||||||
const seenTexts = new Set();
|
const seenTexts = new Set();
|
||||||
const selectors = [
|
const terms = ["website","web design","web develop","need a","looking for","build my","create a","wordpress","landing page","ecommerce","tutor","tutoring","homeschool","math","reading","english","science","grade"];
|
||||||
'div[role="article"]',
|
const now = Date.now();
|
||||||
'div[role="feed"] > div',
|
const DAY_MS = 86400000;
|
||||||
'div.x1yztbdb',
|
const anchors = document.querySelectorAll('a[href*="/posts/"], a[href*="/story.php"], a[href*="/photo.php"], a[href*="/watch"], a[href*="/reel/"], a[href*="/permalink/"]');
|
||||||
'div[data-pagelet]',
|
for (const a of anchors) {
|
||||||
];
|
const h = a.getAttribute('href') || '';
|
||||||
for (const sel of selectors) {
|
if (!h) continue;
|
||||||
const els = document.querySelectorAll(sel);
|
const cell = a.closest('div[style]') || a.parentElement;
|
||||||
for (const el of els) {
|
if (!cell) continue;
|
||||||
const text = (el.innerText || '').trim();
|
const txt = (cell.innerText || '').trim();
|
||||||
if (text.length < 40) continue;
|
if (txt.length < 40) continue;
|
||||||
const key = text.substring(0, 80);
|
const lower = txt.toLowerCase();
|
||||||
|
let matched = false;
|
||||||
|
for (const t of terms) { if (lower.includes(t)) { matched = true; break; } }
|
||||||
|
if (!matched) continue;
|
||||||
|
const key = txt.substring(0, 80);
|
||||||
if (seenTexts.has(key)) continue;
|
if (seenTexts.has(key)) continue;
|
||||||
seenTexts.add(key);
|
seenTexts.add(key);
|
||||||
|
|
||||||
// --- Publisher name ---
|
const isGroup = h.includes('/groups/');
|
||||||
let author = '';
|
let author = '';
|
||||||
const nameEl = el.querySelector('h4, strong, a[role="link"]');
|
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
|
||||||
if (nameEl) {
|
if (nameEl) author = (nameEl.innerText || '').trim();
|
||||||
author = (nameEl.innerText || '').trim();
|
if (!author || author.length > 60 || author.length < 2) author = '';
|
||||||
}
|
let postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
||||||
if (!author) {
|
|
||||||
const links = el.querySelectorAll('a');
|
|
||||||
for (const a of links) {
|
|
||||||
const t = (a.innerText || '').trim();
|
|
||||||
if (t && t.length > 1 && t.length < 60 && /^[A-Za-zÀ-ÿ]/.test(t) && !t.includes('facebook') && !t.includes('/')) {
|
|
||||||
author = t;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Post URL ---
|
|
||||||
let postUrl = '';
|
|
||||||
for (const a of el.querySelectorAll('a')) {
|
|
||||||
const h = (a.getAttribute('href') || '');
|
|
||||||
if (h.includes('/posts/') || h.includes('/photo/') || h.includes('/video/') || h.includes('/groups/') || h.includes('/permalink/')) {
|
|
||||||
postUrl = h.startsWith('http') ? h : 'https://www.facebook.com' + h;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Date from <time datetime> ---
|
|
||||||
let date = '';
|
let date = '';
|
||||||
const timeEl = el.querySelector('time');
|
let ts = 0;
|
||||||
|
const timeEl = cell.querySelector('time');
|
||||||
if (timeEl) {
|
if (timeEl) {
|
||||||
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
date = timeEl.getAttribute('datetime') || timeEl.getAttribute('aria-label') || '';
|
||||||
|
const dtVal = timeEl.getAttribute('datetime');
|
||||||
|
if (dtVal) { ts = new Date(dtVal).getTime(); }
|
||||||
}
|
}
|
||||||
|
results.push({ text: txt, author, url: postUrl, date, isGroup, ts });
|
||||||
results.push({ text, author, url: postUrl, date });
|
|
||||||
}
|
|
||||||
if (results.length > 0) break;
|
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
}''')
|
}''')
|
||||||
@@ -882,15 +1101,11 @@ async def search_facebook(page, context, query: str):
|
|||||||
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
url = f'https://www.facebook.com/search/posts/?q={urllib.parse.quote(query)}'
|
||||||
try:
|
try:
|
||||||
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
await page.goto(url, wait_until='domcontentloaded', timeout=30000)
|
||||||
try:
|
await page.wait_for_timeout(random.randint(5000, 10000))
|
||||||
await page.wait_for_selector('div[role="article"], div[role="feed"]', timeout=15000)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await page.wait_for_timeout(random.randint(3000, 8000))
|
|
||||||
|
|
||||||
await human_scroll(page, steps=random.randint(2, 4), total_delay=random.uniform(6, 15))
|
await human_scroll(page, steps=random.randint(4, 7), total_delay=random.uniform(12, 25))
|
||||||
|
|
||||||
if random.random() < 0.1:
|
if random.random() < 0.3:
|
||||||
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||||
await page.evaluate("window.scrollBy(0, -300)")
|
await page.evaluate("window.scrollBy(0, -300)")
|
||||||
@@ -905,9 +1120,102 @@ async def search_facebook(page, context, query: str):
|
|||||||
|
|
||||||
raw_articles = await _get_article_elements(page)
|
raw_articles = await _get_article_elements(page)
|
||||||
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
|
posts = _extract_posts_from_elements(raw_articles, url) if raw_articles else []
|
||||||
if not posts:
|
|
||||||
raw = await page.evaluate('document.body.innerText')
|
raw = await page.evaluate('document.body.innerText')
|
||||||
posts = _extract_posts_from_text(raw, url)
|
text_posts = _extract_posts_from_text(raw, url)
|
||||||
|
existing = {(p.get('title') or p.get('content',''))[:80] for p in posts}
|
||||||
|
for tp in text_posts:
|
||||||
|
key = (tp.get('title') or tp.get('content',''))[:80]
|
||||||
|
if key not in existing:
|
||||||
|
posts.append(tp)
|
||||||
|
if posts:
|
||||||
|
# Extract author names from profile links in the DOM
|
||||||
|
try:
|
||||||
|
profiles = await page.evaluate(r'''() => {
|
||||||
|
const out = [];
|
||||||
|
const seenTxt = new Set();
|
||||||
|
for (const a of document.querySelectorAll('a[href*="/profile.php"], a[href*="/user/"], a[href*="/people/"], a[href*="/me/"]')) {
|
||||||
|
const name = (a.innerText || '').trim();
|
||||||
|
if (!name || name.length < 3 || name.length > 60) continue;
|
||||||
|
const words = name.split(' ');
|
||||||
|
if (words.length < 2 || words.length > 6) continue;
|
||||||
|
if (!/^[A-Z]/.test(name)) continue;
|
||||||
|
if (name.includes('facebook') || name.includes('/')) continue;
|
||||||
|
const cell = a.closest('div[style]') || a.parentElement;
|
||||||
|
const txt = cell ? (cell.innerText || '').substring(0, 200) : '';
|
||||||
|
if (!txt) continue;
|
||||||
|
const key = txt.substring(0, 80);
|
||||||
|
if (seenTxt.has(key)) continue;
|
||||||
|
seenTxt.add(key);
|
||||||
|
out.push({ name, textKey: key });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}''')
|
||||||
|
if profiles:
|
||||||
|
for p in posts:
|
||||||
|
pk = (p.get('content') or p.get('title') or '')[:80].strip()
|
||||||
|
if not pk: continue
|
||||||
|
for pr in profiles:
|
||||||
|
if pk[:30] in pr['textKey'] or pr['textKey'][:30] in pk:
|
||||||
|
if not p.get('author'):
|
||||||
|
p['author'] = pr['name']
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
meta = await page.evaluate(r'''() => {
|
||||||
|
const out = [];
|
||||||
|
const seenHref = new Set();
|
||||||
|
const all = document.querySelectorAll('a[href]');
|
||||||
|
for (const a of all) {
|
||||||
|
const h = a.href;
|
||||||
|
if (!h || seenHref.has(h)) continue;
|
||||||
|
const u = new URL(h);
|
||||||
|
if (!u.hostname.includes('facebook.com') && !u.hostname.includes('fb.com')) continue;
|
||||||
|
const path = u.pathname;
|
||||||
|
if (path === '/' || path === '/search/' || path === '/marketplace/' || path.startsWith('/messages/') || path.startsWith('/notifications/') || path.startsWith('/settings/')) continue;
|
||||||
|
const isPost = path.includes('/posts/') || path.includes('/videos/') || path.includes('/photos/') || path.startsWith('/story.php') || path.startsWith('/photo.php') || path.startsWith('/watch') || path.includes('/reel/') || path.startsWith('/permalink/');
|
||||||
|
if (!isPost) continue;
|
||||||
|
const cell = a.closest('[style*="position"], [role="article"], div[data-pagelet], div.x1yztbdb, div.x78zum5, div.x1n2onr6') || a.parentElement;
|
||||||
|
if (!cell) continue;
|
||||||
|
const txt = (cell.innerText || '').trim();
|
||||||
|
if (txt.length < 40) continue;
|
||||||
|
let author = '';
|
||||||
|
const nameEl = cell.querySelector('h4, strong, a[role="link"], span[dir="auto"]');
|
||||||
|
if (nameEl) author = (nameEl.innerText || '').trim();
|
||||||
|
if (!author || author.length > 60) author = '';
|
||||||
|
const key = txt.substring(0, 100);
|
||||||
|
seenHref.add(h);
|
||||||
|
out.push({ key, href: h, author, textMap: txt.substring(0,200) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}''')
|
||||||
|
if meta:
|
||||||
|
for p in posts:
|
||||||
|
pk = (p.get('content') or p.get('title') or '')[:100].strip()
|
||||||
|
if not pk: continue
|
||||||
|
for m in meta:
|
||||||
|
mk = m.get('key') or ''
|
||||||
|
if pk[:40] in mk or mk[:40] in pk:
|
||||||
|
if m.get('href') and 'search/' not in m['href']:
|
||||||
|
p['url'] = m['href']
|
||||||
|
if m.get('author') and not p.get('author'):
|
||||||
|
p['author'] = m['author']
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Clean up: remove group names passed off as authors
|
||||||
|
for p in posts:
|
||||||
|
if p.get('author'):
|
||||||
|
a = p['author']
|
||||||
|
al = a.lower()
|
||||||
|
if any(kw in al for kw in BROAD_KEYWORDS) or is_offer(a) or len(a.split()) < 2 or any(w in _NON_NAMES for w in al.split()):
|
||||||
|
p['author'] = ''
|
||||||
|
# Final filter: strip any remaining group posts
|
||||||
|
posts = [p for p in posts if not (
|
||||||
|
'/groups/' in p.get('url', '') or '/group/' in p.get('url', '')
|
||||||
|
or '/pages/' in p.get('url', '')
|
||||||
|
or ' / ' in (p.get('title') or p.get('content') or '')
|
||||||
|
)]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Facebook search '%s' failed: %s", query, e)
|
logger.warning("Facebook search '%s' failed: %s", query, e)
|
||||||
return page, []
|
return page, []
|
||||||
@@ -957,7 +1265,7 @@ def cleanup_chrome():
|
|||||||
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
|
# - Auto-detect: Firefox → Opera → Chrome → Edge (first found wins)
|
||||||
# - If no profile found → Agent (browser-use + ChatOllama) fallback
|
# - If no profile found → Agent (browser-use + ChatOllama) fallback
|
||||||
|
|
||||||
async def scrape_facebook(profile_path: str | None = None, force: bool = False) -> dict:
|
async def scrape_facebook(profile_path: str | None = None, force: bool = False, query: str | None = None) -> dict:
|
||||||
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
|
"""Dispatcher — respect SELECTED_BROWSER, fall through on flag, then Agent."""
|
||||||
effective_path = profile_path or ""
|
effective_path = profile_path or ""
|
||||||
browser_type = ""
|
browser_type = ""
|
||||||
@@ -999,14 +1307,14 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
|
|
||||||
# Firefox path
|
# Firefox path
|
||||||
if browser_type == "firefox":
|
if browser_type == "firefox":
|
||||||
result = await _scrape_with_firefox(effective_path, force)
|
result = await _scrape_with_firefox(effective_path, force, query)
|
||||||
if result.get("success") or not result.get("flagged"):
|
if result.get("success") or not result.get("flagged"):
|
||||||
return result
|
return result
|
||||||
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
|
logger.warning("Firefox flagged (%s), trying Agent", result.get("flag_reason", "unknown"))
|
||||||
return await _scrape_with_agent(force)
|
return await _scrape_with_agent(force)
|
||||||
|
|
||||||
# Chromium-based (chrome / opera / edge)
|
# Chromium-based (chrome / opera / edge)
|
||||||
result = await _scrape_with_chromium(effective_path, browser_type, force)
|
result = await _scrape_with_chromium(effective_path, browser_type, force, query)
|
||||||
if result.get("success") or not result.get("flagged"):
|
if result.get("success") or not result.get("flagged"):
|
||||||
return result
|
return result
|
||||||
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
|
logger.warning("%s flagged (%s), trying Agent", browser_type, result.get("flag_reason", "unknown"))
|
||||||
@@ -1021,7 +1329,7 @@ async def scrape_facebook(profile_path: str | None = None, force: bool = False)
|
|||||||
# human browsing). If flagged by Facebook, returns flagged=True for the
|
# human browsing). If flagged by Facebook, returns flagged=True for the
|
||||||
# dispatcher to fall through to the Agent.
|
# dispatcher to fall through to the Agent.
|
||||||
|
|
||||||
async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
async def _scrape_with_firefox(profile_path: str, force: bool, query: str | None = None) -> dict:
|
||||||
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
"""Scrape Facebook using Firefox + persistent real profile (no cookie injection)."""
|
||||||
if not profile_path:
|
if not profile_path:
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
|
||||||
@@ -1075,9 +1383,13 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
|||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
all_posts = []
|
||||||
|
if query:
|
||||||
|
query_pool = _search_list_for_query(query)
|
||||||
|
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
|
||||||
|
else:
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
||||||
for i, query in enumerate(searches):
|
for i, sq in enumerate(searches):
|
||||||
page, posts = await search_facebook(page, context, query)
|
page, posts = await search_facebook(page, context, sq)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
if not posts:
|
if not posts:
|
||||||
continue
|
continue
|
||||||
@@ -1128,6 +1440,12 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
# ── Chromium Scraper ─────────────────────────────────────────────────
|
# ── Chromium Scraper ─────────────────────────────────────────────────
|
||||||
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
|
# Generic scraper for Chrome/Edge/Opera. Uses the same structure as the
|
||||||
# Firefox scraper but with Chromium-specific launch config:
|
# Firefox scraper but with Chromium-specific launch config:
|
||||||
@@ -1138,7 +1456,7 @@ async def _scrape_with_firefox(profile_path: str, force: bool) -> dict:
|
|||||||
# Anti-detection script differs slightly from Firefox variant.
|
# Anti-detection script differs slightly from Firefox variant.
|
||||||
# Same decoy-skip and flagged-fallback behavior as Firefox.
|
# Same decoy-skip and flagged-fallback behavior as Firefox.
|
||||||
|
|
||||||
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False) -> dict:
|
async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = False, query: str | None = None) -> dict:
|
||||||
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
|
"""Scrape Facebook using a Chromium-based browser profile (Chrome/Edge/Opera)."""
|
||||||
if not profile_path:
|
if not profile_path:
|
||||||
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
|
return {"success": False, "leads": [], "flagged": False, "flag_reason": None, "error": "No profile path"}
|
||||||
@@ -1200,9 +1518,13 @@ async def _scrape_with_chromium(profile_path: str, browser: str, force: bool = F
|
|||||||
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
return {"success": True, "leads": [], "flagged": False, "flag_reason": None, "error": None}
|
||||||
|
|
||||||
all_posts = []
|
all_posts = []
|
||||||
|
if query:
|
||||||
|
query_pool = _search_list_for_query(query)
|
||||||
|
searches = [query] + random.sample(query_pool, k=random.randint(1, 2))
|
||||||
|
else:
|
||||||
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
searches = random.sample(FB_SEARCHES, k=random.randint(2, 4))
|
||||||
for i, query in enumerate(searches):
|
for i, sq in enumerate(searches):
|
||||||
page, posts = await search_facebook(page, context, query)
|
page, posts = await search_facebook(page, context, sq)
|
||||||
all_posts.extend(posts)
|
all_posts.extend(posts)
|
||||||
if not posts:
|
if not posts:
|
||||||
continue
|
continue
|
||||||
@@ -1360,6 +1682,8 @@ async def ask_ollama(prompt: str) -> str:
|
|||||||
async def classify_leads(results: list[dict]) -> list[dict]:
|
async def classify_leads(results: list[dict]) -> list[dict]:
|
||||||
if not results:
|
if not results:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# ── 1. AI classification ─────────────────────────────────────────
|
||||||
briefs = [r["title"][:200] for r in results]
|
briefs = [r["title"][:200] for r in results]
|
||||||
prompt = f"""Classify each post as LEAD or NOT.
|
prompt = f"""Classify each post as LEAD or NOT.
|
||||||
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
LEAD = someone REQUESTING/POSTING/WANTING a website built, designed, or created for them.
|
||||||
@@ -1371,11 +1695,13 @@ NOT LEAD:
|
|||||||
- Recruiting employees, hiring staff, looking for business partners
|
- Recruiting employees, hiring staff, looking for business partners
|
||||||
- Selling products, promoting services, affiliate offers
|
- Selling products, promoting services, affiliate offers
|
||||||
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
- "Need web hosting", "Looking for a partner", "Looking for content writer", "Video spokesperson"
|
||||||
|
- Posts from groups, communities, or pages (group announcements, group posts, page posts)
|
||||||
|
- Posts containing the word "group", "page", "community", "creators" — these are NEVER individual leads
|
||||||
|
|
||||||
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
For each numbered post, answer ONLY "yes" (LEAD) or "no" (NOT LEAD):
|
||||||
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
{chr(10).join(f'{i+1}. {t}' for i, t in enumerate(briefs))}
|
||||||
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
Return a JSON array like ["yes","no","yes"] matching the order above."""
|
||||||
ai_succeeded = False
|
ai_leads: list[dict] = []
|
||||||
try:
|
try:
|
||||||
raw = await ask_ollama(prompt)
|
raw = await ask_ollama(prompt)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
@@ -1388,43 +1714,138 @@ Return a JSON array like ["yes","no","yes"] matching the order above."""
|
|||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
answers = json.loads(raw)
|
answers = json.loads(raw)
|
||||||
if isinstance(answers, list) and len(answers) == len(results):
|
if isinstance(answers, list) and len(answers) == len(results):
|
||||||
ai_succeeded = True
|
|
||||||
filtered = []
|
|
||||||
for i, ans in enumerate(answers):
|
for i, ans in enumerate(answers):
|
||||||
if isinstance(ans, str) and ans.lower() == 'yes':
|
if isinstance(ans, str) and ans.lower() == 'yes':
|
||||||
filtered.append(results[i])
|
ai_leads.append(results[i])
|
||||||
if filtered:
|
logger.info("AI classified %d/%d as LEAD", len(ai_leads), len(results))
|
||||||
return filtered[:10]
|
|
||||||
logger.info("AI classified all %d items as NOT LEAD — returning empty", len(results))
|
|
||||||
return []
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("AI classification failed, falling back to keyword filter: %s", e)
|
logger.warning("AI classification failed: %s", e)
|
||||||
|
|
||||||
if not ai_succeeded:
|
# ── 2. Keyword fallback (always runs) ────────────────────────────
|
||||||
strict_keywords = [
|
web_terms = [
|
||||||
"website", "web design", "web develop", "web dev",
|
"website", "web design", "web develop", "web dev",
|
||||||
|
"web designer", "web developer",
|
||||||
"build my website", "build a website", "create a website",
|
"build my website", "build a website", "create a website",
|
||||||
"need web", "looking for web", "new website",
|
"landing page", "wordpress", "ecommerce",
|
||||||
"landing page", "wordpress",
|
"my website", "business website",
|
||||||
"need a website", "my website", "business website",
|
"site for my", "site for my business",
|
||||||
"need a designer", "help with my website",
|
"new website", "redesign my website",
|
||||||
"redesign", "update my website",
|
"help with my website", "update my website",
|
||||||
|
"make a website", "make my website",
|
||||||
|
"website for my",
|
||||||
|
"online store", "online shop",
|
||||||
|
"build my site", "build a site",
|
||||||
|
"set up a website", "set up my website",
|
||||||
|
"custom website",
|
||||||
|
"shopify",
|
||||||
|
"my site",
|
||||||
|
"webpage", "web page",
|
||||||
|
]
|
||||||
|
request_terms = [
|
||||||
"looking for", "need a", "need an", "looking to",
|
"looking for", "need a", "need an", "looking to",
|
||||||
"need someone", "hire a", "want someone",
|
"need someone", "hire a", "want someone",
|
||||||
"need help with", "would like", "build me",
|
"need help with", "would like", "build me",
|
||||||
"design my", "make me a", "create my",
|
"design my", "make me a", "create my",
|
||||||
|
"looking", "need", "want", "help",
|
||||||
|
"who can", "i need",
|
||||||
|
"recommend", "anyone know", "anyone recommend",
|
||||||
|
"know a", "know any", "recommendation",
|
||||||
|
"suggest", "looking for recommendations",
|
||||||
|
"can anyone", "does anyone",
|
||||||
|
"dm me", "pm me", "message me",
|
||||||
|
"quote for",
|
||||||
|
"can you help",
|
||||||
|
"how much",
|
||||||
|
"price for",
|
||||||
|
]
|
||||||
|
keyword_leads: list[dict] = []
|
||||||
|
offer_reject = [
|
||||||
|
'i build website', 'i offer web', 'i am a web developer',
|
||||||
|
'affordable web design package', 'web hosting',
|
||||||
|
'i do web design', 'i develop websites',
|
||||||
|
'do you need a', 'do you want a', 'we can build',
|
||||||
|
'i can build', 'my portfolio', 'reasonable price',
|
||||||
|
'whatsapp me', 'looking for a business', 'looking for client',
|
||||||
|
'help your business', 'i am a web', 'contact me',
|
||||||
|
'we offer web', 'we provide web',
|
||||||
|
'take the quiz', 'homeschool', 'your home tutor',
|
||||||
|
'link in bio', 'apply now', 'get started',
|
||||||
|
'for only', 'low price', 'hit me up',
|
||||||
|
'send me a message', 'i do website', 'we do website',
|
||||||
|
'we do web', 'i do web',
|
||||||
|
'website designer / web developer', 'website & software creators',
|
||||||
|
'website builders for small businesses', 'australia web designers',
|
||||||
|
'south africa', 'wix website design',
|
||||||
|
'for sale', 'selling my', 'premium',
|
||||||
|
'i\'m selling', 'i\'m offering', 'we\'re offering',
|
||||||
|
'free ecommerce', 'free website design',
|
||||||
|
'starting a', 'looking for a few businesses',
|
||||||
|
# Group-related rejections
|
||||||
|
'group', ' i need a website group', 'south africa web', 'philippines web', 'australia web',
|
||||||
|
'i can help', 'inbox me', 'dm me', 'pm me', 'message me for',
|
||||||
|
'best price', 'discount', 'reach out', 'check out my', 'check this',
|
||||||
|
'website for your', 'price start', 'price begin', 'website creator',
|
||||||
|
'website & software', 'creators &', 'creators marketplace',
|
||||||
|
'website group', 'page group',
|
||||||
|
# Self-promotion rejections
|
||||||
|
'i\'m a web', "i'm a web", 'i am a full stack', "i'm a full stack", 'i\'m a full stack',
|
||||||
|
'freelance opportunity', 'looking for new project', 'looking for new work',
|
||||||
|
'full stack web', 'mern stack', 'responsive business website',
|
||||||
|
'i build website', 'i build shopify', 'i build wordpress',
|
||||||
|
'we build website', 'we build shopify', 'we build wordpress',
|
||||||
|
'i create website', 'we create website',
|
||||||
|
'full time position', 'full time job', 'remote position', 'remote job',
|
||||||
|
'help wanted', 'now hiring', 'job opening',
|
||||||
|
'website speed', 'speed optimization', 'on page seo', 'off page seo',
|
||||||
|
'elementor pro', 'woocommerce', 'acf', 'custom post type',
|
||||||
|
'send you the link', 'comment website',
|
||||||
|
'for free', 'no coding', 'make money', 'website for free',
|
||||||
|
'part time job', 'part time position',
|
||||||
|
'years of experience', 'years of teaching',
|
||||||
]
|
]
|
||||||
filtered = []
|
|
||||||
for r in results:
|
for r in results:
|
||||||
t = r['title'].lower()
|
t = r['title'].lower()
|
||||||
if not any(kw in t for kw in strict_keywords):
|
has_web = any(kw in t for kw in web_terms)
|
||||||
|
has_request = any(kw in t for kw in request_terms)
|
||||||
|
if not has_web or not has_request:
|
||||||
continue
|
continue
|
||||||
if any(kw in t for kw in ['i build website', 'i offer web', 'i am a web developer',
|
if any(kw in t for kw in offer_reject):
|
||||||
'affordable web design package', 'web hosting']):
|
|
||||||
continue
|
continue
|
||||||
filtered.append(r)
|
keyword_leads.append(r)
|
||||||
return filtered[:10]
|
|
||||||
return []
|
# ── 3. Merge: prefer AI leads, supplement with keywords to reach 5 ──
|
||||||
|
seen_titles: set[int] = set()
|
||||||
|
merged: list[dict] = []
|
||||||
|
for r in ai_leads + keyword_leads:
|
||||||
|
key = hash(r.get('title', ''))
|
||||||
|
if key not in seen_titles:
|
||||||
|
seen_titles.add(key)
|
||||||
|
merged.append(r)
|
||||||
|
# Final sweep: strip any remaining offers or group posts from merged
|
||||||
|
group_words = ['group', ' groups', 'page:', 'page |', 'community', 'creators', 'marketplace']
|
||||||
|
merged = [r for r in merged if not any(kw in (r.get('title','') or '').lower() for kw in offer_reject)]
|
||||||
|
merged = [r for r in merged if not any(gw in (r.get('title','') or '').lower() for gw in group_words)]
|
||||||
|
|
||||||
|
# Fill to 5 with loose keyword matches (at least web OR request term)
|
||||||
|
if len(merged) < 5:
|
||||||
|
for r in results:
|
||||||
|
key = hash(r.get('title', ''))
|
||||||
|
if key in seen_titles:
|
||||||
|
continue
|
||||||
|
t = r['title'].lower()
|
||||||
|
if not (any(kw in t for kw in web_terms) or any(kw in t for kw in request_terms)):
|
||||||
|
continue
|
||||||
|
if any(kw in t for kw in offer_reject):
|
||||||
|
continue
|
||||||
|
if any(gw in t for gw in group_words):
|
||||||
|
continue
|
||||||
|
seen_titles.add(key)
|
||||||
|
merged.append(r)
|
||||||
|
if len(merged) >= 5:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info("classify_leads: %d merged (%d AI + %d keyword) from %d raw", len(merged), len(ai_leads), len(keyword_leads), len(results))
|
||||||
|
return merged[:10]
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
# FastAPI Endpoints
|
# FastAPI Endpoints
|
||||||
@@ -1541,8 +1962,8 @@ async def agent_run(task: str = Body(..., embed=True)):
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
@app.post("/scrape/facebook")
|
@app.post("/scrape/facebook")
|
||||||
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False)):
|
async def scrape_facebook_endpoint(profile_path: str | None = Query(None), force: bool = Query(False), query: str | None = Query(None)):
|
||||||
result = await scrape_facebook(profile_path, force)
|
result = await scrape_facebook(profile_path, force, query)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
+2
-10
@@ -1,10 +1,2 @@
|
|||||||
{"job_title":"Software Developer","keywords":["developer","programmer","software engineer","coder","full stack","backend","frontend"],"industry":"Technology","description":"Builds and maintains software applications and systems"}
|
{"job_title":"Website Creation","keywords":["need a website","build my website","create a website for me","website for my business","need someone to build","who can build me","looking for a web developer","need a web designer","i need a website","need help with my website","looking for someone to create","need a site for","want a website for","need ecommerce website","need landing page","website for my small business","need my website redesigned","can someone build me","anyone know a web developer","recommend a web developer","need a new website"],"industry":"Technology","description":"Find people asking for websites, landing pages, or online stores to be built"}
|
||||||
{"job_title":"Marketing Specialist","keywords":["marketing","digital marketing","brand manager","content marketer","social media"],"industry":"Marketing","description":"Plans and executes marketing campaigns across channels"}
|
{"job_title":"Tutoring","keywords":["need a tutor","looking for a tutor","tutor for my child","need help with homework","private tutor needed","looking for tutoring","need a math tutor","english tutor needed","online tutor for","lessons for my child","help my child with","need someone to tutor","looking for someone to teach","tutoring for my","need help learning","recommend a tutor","anyone know a tutor","need a private tutor","tutoring services for my child","need academic help"],"industry":"Education","description":"Find parents and students asking for tutoring services"}
|
||||||
{"job_title":"Sales Representative","keywords":["sales rep","account executive","business development","sales consultant"],"industry":"Sales","description":"Drives revenue through client acquisition and relationship management"}
|
|
||||||
{"job_title":"Project Manager","keywords":["project manager","program manager","scrum master","agile coach"],"industry":"Business","description":"Oversees project timelines, resources, and deliverables"}
|
|
||||||
{"job_title":"Graphic Designer","keywords":["designer","graphic designer","ui designer","ux designer","visual designer"],"industry":"Creative","description":"Creates visual concepts and designs for digital and print media"}
|
|
||||||
{"job_title":"Data Analyst","keywords":["data analyst","business analyst","data scientist","analytics"],"industry":"Technology","description":"Analyzes data to provide actionable business insights"}
|
|
||||||
{"job_title":"Customer Support Specialist","keywords":["customer support","customer service","support agent","help desk"],"industry":"Customer Service","description":"Assists customers with inquiries, issues, and product support"}
|
|
||||||
{"job_title":"Human Resources Manager","keywords":["HR manager","HR","recruiter","talent acquisition","people operations"],"industry":"Human Resources","description":"Manages recruitment, employee relations, and HR operations"}
|
|
||||||
{"job_title":"Financial Advisor","keywords":["financial advisor","financial planner","wealth manager","investment advisor"],"industry":"Finance","description":"Provides financial guidance and investment planning to clients"}
|
|
||||||
{"job_title":"Operations Manager","keywords":["operations manager","operations","logistics","supply chain"],"industry":"Business","description":"Oversees daily operations and process optimization"}
|
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
|
|||||||
-- admin_demo / AdminAccess@2026
|
-- admin_demo / AdminAccess@2026
|
||||||
-- sales_demo / SalesAccess@2026
|
-- sales_demo / SalesAccess@2026
|
||||||
-- dev_demo / DevTesting@2026
|
-- dev_demo / DevTesting@2026
|
||||||
|
-- ewan (password change required on first login)
|
||||||
|
-- caitlin (password change required on first login)
|
||||||
|
-- dillen (password change required on first login)
|
||||||
|
|
||||||
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
|
||||||
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
|
||||||
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
|
|||||||
'Sales', 'User', TRUE, TRUE),
|
'Sales', 'User', TRUE, TRUE),
|
||||||
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
|
||||||
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
|
||||||
'Dev', 'User', TRUE, TRUE)
|
'Dev', 'User', TRUE, TRUE),
|
||||||
|
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
|
||||||
|
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
|
||||||
|
'Ewan', 'Scheepers', TRUE, TRUE),
|
||||||
|
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
|
||||||
|
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
|
||||||
|
'Caitlin', 'Hermanus', TRUE, TRUE),
|
||||||
|
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
|
||||||
|
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
|
||||||
|
'Dillen', 'van der Merwe', TRUE, TRUE)
|
||||||
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
|
||||||
|
|
||||||
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
|
||||||
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
|
|||||||
|
|
||||||
-- Set created_by for other users (SUPER_ADMIN created them)
|
-- Set created_by for other users (SUPER_ADMIN created them)
|
||||||
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
|
||||||
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
|
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
|
||||||
AND created_by IS NULL;
|
AND created_by IS NULL;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
|
|||||||
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
|
||||||
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
|
||||||
|
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|||||||
@@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL
|
|||||||
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
|
||||||
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
|
||||||
|
|
||||||
|
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
|
||||||
|
-- They will set their own passwords on first login (password_change_required=TRUE).
|
||||||
|
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
|
||||||
|
-- after users have set their chosen passwords.
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- FUTURE REQUIREMENT NOTE:
|
-- FUTURE REQUIREMENT NOTE:
|
||||||
-- If this system is ever exposed publicly, remove reversible password storage
|
-- If this system is ever exposed publicly, remove reversible password storage
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE scheduled_events
|
||||||
|
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
ALTER TABLE scheduled_events
|
||||||
|
DROP CONSTRAINT IF EXISTS chk_event_type,
|
||||||
|
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
|
||||||
|
|
||||||
|
ALTER TABLE scheduled_events
|
||||||
|
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE scheduled_events
|
||||||
|
ALTER COLUMN start_time DROP NOT NULL;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Fixes: password_change_required + audit trigger UUID cast
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- 1. All users use the passwords already set in the seed — no forced change
|
||||||
|
UPDATE users SET password_change_required = FALSE WHERE password_change_required = TRUE;
|
||||||
|
|
||||||
|
-- 2. Fix audit_password_change trigger: current_setting returns TEXT but
|
||||||
|
-- audit_logs.changed_by is UUID — cast to UUID to prevent type error
|
||||||
|
CREATE OR REPLACE FUNCTION audit_password_change()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF OLD.password_hash IS DISTINCT FROM NEW.password_hash THEN
|
||||||
|
INSERT INTO audit_logs (
|
||||||
|
table_name, record_id, action, old_data, new_data, changed_by, ip_address
|
||||||
|
) VALUES (
|
||||||
|
'users',
|
||||||
|
NEW.id,
|
||||||
|
'UPDATE',
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', OLD.password_change_required),
|
||||||
|
jsonb_build_object('password_changed', true, 'password_change_required', NEW.password_change_required),
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::UUID,
|
||||||
|
NULLIF(current_setting('app.current_ip', true), '')
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
-- 3. Re-enable the trigger (was disabled as workaround for the UUID bug)
|
||||||
|
ALTER TABLE users ENABLE TRIGGER trg_audit_password_change;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Project Management — Post-sale project tracking with milestones
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_statuses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(7),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
customer_id UUID NOT NULL REFERENCES customers(id),
|
||||||
|
opportunity_id UUID REFERENCES opportunities(id),
|
||||||
|
owner_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status_id UUID NOT NULL REFERENCES project_statuses(id),
|
||||||
|
start_date DATE,
|
||||||
|
target_end_date DATE,
|
||||||
|
actual_end_date DATE,
|
||||||
|
budget DECIMAL(15,2),
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_projects_customer ON projects(customer_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_projects_owner ON projects(owner_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_projects_status ON projects(status_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_projects_created ON projects(created_at) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS project_milestones (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
start_date DATE,
|
||||||
|
due_date DATE,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT chk_milestone_status CHECK (status IN ('pending','in_progress','completed','cancelled'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_milestones_project ON project_milestones(project_id);
|
||||||
|
CREATE INDEX idx_milestones_status ON project_milestones(status);
|
||||||
|
CREATE INDEX idx_milestones_due ON project_milestones(due_date);
|
||||||
|
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS milestone_id UUID REFERENCES project_milestones(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id) WHERE project_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tasks_milestone ON tasks(milestone_id) WHERE milestone_id IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Seed project statuses
|
||||||
|
INSERT INTO project_statuses (name, color, sort_order) VALUES
|
||||||
|
('Planning', '#6366f1', 1),
|
||||||
|
('In Progress', '#22c55e', 2),
|
||||||
|
('Review', '#eab308', 3),
|
||||||
|
('Completed', '#22d3ee', 4),
|
||||||
|
('On Hold', '#f97316', 5),
|
||||||
|
('Cancelled', '#ef4444', 6)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
-- Seed a sample project from the existing TechCorp opportunity
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_customer_id UUID;
|
||||||
|
v_opportunity_id UUID;
|
||||||
|
v_owner_id UUID;
|
||||||
|
v_planning_id UUID;
|
||||||
|
v_progress_id UUID;
|
||||||
|
v_project_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_customer_id FROM customers LIMIT 1;
|
||||||
|
SELECT id INTO v_opportunity_id FROM opportunities LIMIT 1;
|
||||||
|
SELECT u.id INTO v_owner_id FROM users u JOIN user_roles ur ON ur.user_id = u.id JOIN roles r ON r.id = ur.role_id WHERE r.name = 'SALES_USER' LIMIT 1;
|
||||||
|
IF v_owner_id IS NULL THEN SELECT id INTO v_owner_id FROM users LIMIT 1; END IF;
|
||||||
|
SELECT id INTO v_planning_id FROM project_statuses WHERE name = 'Planning';
|
||||||
|
SELECT id INTO v_progress_id FROM project_statuses WHERE name = 'In Progress';
|
||||||
|
|
||||||
|
IF v_customer_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM projects WHERE name = 'TechCorp Website Redesign') THEN
|
||||||
|
INSERT INTO projects (customer_id, opportunity_id, owner_id, name, description, status_id, start_date, target_end_date, budget, created_by)
|
||||||
|
VALUES (v_customer_id, v_opportunity_id, v_owner_id, 'TechCorp Website Redesign',
|
||||||
|
'Full website redesign including homepage, services pages, and contact forms',
|
||||||
|
v_planning_id, CURRENT_DATE, CURRENT_DATE + INTERVAL '60 days', 25000.00, v_owner_id)
|
||||||
|
RETURNING id INTO v_project_id;
|
||||||
|
|
||||||
|
INSERT INTO project_milestones (project_id, name, description, sort_order, status, start_date, due_date) VALUES
|
||||||
|
(v_project_id, 'Requirements & Wireframes', 'Gather requirements, create wireframes for approval', 1, 'in_progress', CURRENT_DATE, CURRENT_DATE + INTERVAL '10 days'),
|
||||||
|
(v_project_id, 'Design Mockups', 'Create visual design mockups for all pages', 2, 'pending', CURRENT_DATE + INTERVAL '10 days', CURRENT_DATE + INTERVAL '20 days'),
|
||||||
|
(v_project_id, 'Frontend Development', 'Build responsive frontend components', 3, 'pending', CURRENT_DATE + INTERVAL '20 days', CURRENT_DATE + INTERVAL '35 days'),
|
||||||
|
(v_project_id, 'Backend Integration', 'Integrate CMS, forms, and email functionality', 4, 'pending', CURRENT_DATE + INTERVAL '35 days', CURRENT_DATE + INTERVAL '45 days'),
|
||||||
|
(v_project_id, 'Testing & QA', 'Cross-browser testing, performance optimization', 5, 'pending', CURRENT_DATE + INTERVAL '45 days', CURRENT_DATE + INTERVAL '55 days'),
|
||||||
|
(v_project_id, 'Launch', 'Deploy to production, final sign-off', 6, 'pending', CURRENT_DATE + INTERVAL '55 days', CURRENT_DATE + INTERVAL '60 days');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
used_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Proposal/Quote Builder + Time Tracking
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ── Proposals / Quotes ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proposal_statuses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
color VARCHAR(7),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO proposal_statuses (name, color, sort_order) VALUES
|
||||||
|
('Draft', '#6366f1', 1),
|
||||||
|
('Sent', '#eab308', 2),
|
||||||
|
('Viewed', '#22d3ee', 3),
|
||||||
|
('Accepted', '#22c55e', 4),
|
||||||
|
('Declined', '#ef4444', 5),
|
||||||
|
('Expired', '#a1a1aa', 6)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proposals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
lead_id UUID REFERENCES leads(id),
|
||||||
|
customer_id UUID REFERENCES customers(id),
|
||||||
|
opportunity_id UUID REFERENCES opportunities(id),
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
proposal_number VARCHAR(50) NOT NULL,
|
||||||
|
status_id UUID NOT NULL REFERENCES proposal_statuses(id),
|
||||||
|
content JSONB,
|
||||||
|
subtotal DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
tax_percent DECIMAL(5,2) DEFAULT 15,
|
||||||
|
tax_amount DECIMAL(15,2) DEFAULT 0,
|
||||||
|
total_amount DECIMAL(15,2) NOT NULL DEFAULT 0,
|
||||||
|
valid_until DATE,
|
||||||
|
notes TEXT,
|
||||||
|
terms TEXT DEFAULT 'Payment due within 30 days. 50% deposit required to commence work.',
|
||||||
|
created_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_proposal_number ON proposals(proposal_number) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_proposals_customer ON proposals(customer_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_proposals_lead ON proposals(lead_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_proposals_status ON proposals(status_id) WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX idx_proposals_created ON proposals(created_at) WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proposal_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
|
||||||
|
product_id UUID REFERENCES products(id),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
quantity INT NOT NULL DEFAULT 1 CHECK (quantity > 0),
|
||||||
|
unit_price DECIMAL(15,2) NOT NULL CHECK (unit_price >= 0),
|
||||||
|
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0 CHECK (discount_percent >= 0 AND discount_percent <= 100),
|
||||||
|
total_price DECIMAL(15,2) NOT NULL CHECK (total_price >= 0),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_proposal_items_proposal ON proposal_items(proposal_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS proposal_signatures (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE,
|
||||||
|
signatory_name VARCHAR(255) NOT NULL,
|
||||||
|
signatory_email VARCHAR(255) NOT NULL,
|
||||||
|
signature_data TEXT,
|
||||||
|
ip_address INET,
|
||||||
|
signed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
is_valid BOOLEAN NOT NULL DEFAULT TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_proposal_signatures_proposal ON proposal_signatures(proposal_id);
|
||||||
|
|
||||||
|
-- ── Time Tracking ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
project_id UUID REFERENCES projects(id),
|
||||||
|
milestone_id UUID REFERENCES project_milestones(id),
|
||||||
|
task_id UUID REFERENCES tasks(id),
|
||||||
|
description TEXT,
|
||||||
|
date DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
start_time TIMESTAMPTZ,
|
||||||
|
end_time TIMESTAMPTZ,
|
||||||
|
duration_minutes INT NOT NULL CHECK (duration_minutes > 0),
|
||||||
|
billable BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
hourly_rate DECIMAL(15,2),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_time_entries_user ON time_entries(user_id);
|
||||||
|
CREATE INDEX idx_time_entries_project ON time_entries(project_id);
|
||||||
|
CREATE INDEX idx_time_entries_date ON time_entries(date);
|
||||||
|
CREATE INDEX idx_time_entries_billable ON time_entries(billable) WHERE billable = TRUE;
|
||||||
|
|
||||||
|
-- Seed sample time entry
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_project_id UUID;
|
||||||
|
BEGIN
|
||||||
|
SELECT id INTO v_user_id FROM users LIMIT 1;
|
||||||
|
SELECT id INTO v_project_id FROM projects LIMIT 1;
|
||||||
|
IF v_user_id IS NOT NULL AND v_project_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM time_entries) THEN
|
||||||
|
INSERT INTO time_entries (user_id, project_id, description, date, duration_minutes, billable, hourly_rate)
|
||||||
|
VALUES
|
||||||
|
(v_user_id, v_project_id, 'Initial planning and requirements gathering', CURRENT_DATE - 2, 240, TRUE, 1500),
|
||||||
|
(v_user_id, v_project_id, 'Wireframe design', CURRENT_DATE - 1, 180, TRUE, 1500),
|
||||||
|
(v_user_id, v_project_id, 'Team standup and sprint planning', CURRENT_DATE, 60, FALSE, NULL);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Custom Fields — Adaptable fields per entity type (leads, customers, etc.)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_field_definitions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
entity_type VARCHAR(50) NOT NULL,
|
||||||
|
field_key VARCHAR(100) NOT NULL,
|
||||||
|
field_label VARCHAR(255) NOT NULL,
|
||||||
|
field_type VARCHAR(50) NOT NULL DEFAULT 'text',
|
||||||
|
options JSONB,
|
||||||
|
placeholder TEXT,
|
||||||
|
default_value TEXT,
|
||||||
|
section VARCHAR(100),
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_custom_field_entity_key UNIQUE (entity_type, field_key),
|
||||||
|
CONSTRAINT chk_custom_field_type CHECK (field_type IN ('text','number','date','select','multi_select','boolean','url','email','phone'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_custom_fields_entity ON custom_field_definitions(entity_type, is_active, sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE leads ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
|
||||||
|
ALTER TABLE customers ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
|
||||||
|
ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
|
||||||
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS custom_fields JSONB DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Seed sample fields
|
||||||
|
INSERT INTO custom_field_definitions (entity_type, field_key, field_label, field_type, options, section, sort_order) VALUES
|
||||||
|
('lead', 'service_type', 'Service Type', 'select', '["Web Development","Mobile App","SEO","Consulting","Retainer","Maintenance"]', 'Details', 1),
|
||||||
|
('lead', 'budget_confirmed', 'Budget Confirmed', 'boolean', NULL, 'Details', 2),
|
||||||
|
('lead', 'referral_source', 'Referral Source', 'text', NULL, 'Details', 3),
|
||||||
|
('customer', 'industry_vertical', 'Industry Vertical', 'select', '["Fintech","Healthcare","E-commerce","Education","Real Estate","Hospitality","Other"]', 'Business', 1),
|
||||||
|
('customer', 'vat_number', 'VAT Number', 'text', NULL, 'Business', 2),
|
||||||
|
('project', 'project_type', 'Project Type', 'select', '["New Build","Redesign","Maintenance","Consulting","Retainer"]', 'Details', 1),
|
||||||
|
('project', 'hosting_provider', 'Hosting Provider', 'text', NULL, 'Technical', 2),
|
||||||
|
('project', 'tech_stack', 'Tech Stack', 'multi_select', '["React","Next.js","Node.js","Python","PHP","WordPress","Shopify","Laravel","Vue","Angular"]', 'Technical', 3),
|
||||||
|
('opportunity', 'competitor', 'Competitor', 'text', NULL, 'Details', 1),
|
||||||
|
('opportunity', 'decision_timeline', 'Decision Timeline', 'select', '["This Week","This Month","This Quarter","Next Quarter","Not Sure"]', 'Details', 2)
|
||||||
|
ON CONFLICT (entity_type, field_key) DO NOTHING;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Resource Planning — Team capacity, utilisation rates, availability
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS capacity_hours DECIMAL(5,2) NOT NULL DEFAULT 40;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_encrypted TEXT;
|
||||||
|
|
||||||
|
-- Set default capacities for existing users
|
||||||
|
UPDATE users SET capacity_hours = 40 WHERE capacity_hours IS NULL;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Lead Scoring ML — Configurable scoring engine with historical training
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_scoring_config (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
feature_name VARCHAR(100) NOT NULL UNIQUE,
|
||||||
|
feature_type VARCHAR(50) NOT NULL DEFAULT 'keyword',
|
||||||
|
field_ref VARCHAR(100) NOT NULL,
|
||||||
|
weight DECIMAL(5,2) NOT NULL DEFAULT 1.00,
|
||||||
|
value_map JSONB,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_lead_scoring_active ON lead_scoring_config(is_active);
|
||||||
|
|
||||||
|
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score INT DEFAULT 0;
|
||||||
|
ALTER TABLE leads ADD COLUMN IF NOT EXISTS ml_score_details JSONB;
|
||||||
|
|
||||||
|
-- Seed default scoring features
|
||||||
|
INSERT INTO lead_scoring_config (feature_name, feature_type, field_ref, weight, value_map) VALUES
|
||||||
|
('Has Email', 'boolean', 'email', 5, '{"true": 5, "false": 0}'),
|
||||||
|
('Has Phone', 'boolean', 'phone', 5, '{"true": 5, "false": 0}'),
|
||||||
|
('Source: Referral', 'keyword', 'source', 15, '{"referral": 15, "friend": 15, "word_of_mouth": 15}'),
|
||||||
|
('Source: Website', 'keyword', 'source', 10, '{"website": 10, "organic": 8, "direct": 8}'),
|
||||||
|
('Source: Social', 'keyword', 'source', 8, '{"facebook": 8, "linkedin": 8, "twitter": 5, "instagram": 5}'),
|
||||||
|
('Source: Paid', 'keyword', 'source', 6, '{"google_ads": 6, "paid": 6, "ppc": 6}'),
|
||||||
|
('Company Name Present', 'boolean', 'company_name', 10, '{"true": 10, "false": 0}'),
|
||||||
|
('Description Length', 'range', 'description', 10, '{"0": 0, "50": 5, "200": 10, "500": 15}')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- Update ml_score for all existing leads
|
||||||
|
UPDATE leads SET ml_score = 0 WHERE ml_score IS NULL;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Email Sequences / Drip Campaigns — Automated follow-up sequences
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaigns (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'draft',
|
||||||
|
trigger_type VARCHAR(50) NOT NULL DEFAULT 'manual',
|
||||||
|
trigger_config JSONB,
|
||||||
|
created_by UUID REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
step_order INT NOT NULL DEFAULT 0,
|
||||||
|
delay_days INT NOT NULL DEFAULT 0,
|
||||||
|
subject VARCHAR(500) NOT NULL,
|
||||||
|
body_text TEXT,
|
||||||
|
body_html TEXT,
|
||||||
|
variables JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_subscribers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID NOT NULL REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
lead_id UUID REFERENCES leads(id) ON DELETE SET NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
contact_name VARCHAR(255),
|
||||||
|
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
unsubscribed_at TIMESTAMPTZ,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||||
|
current_step INT NOT NULL DEFAULT 0,
|
||||||
|
last_sent_at TIMESTAMPTZ,
|
||||||
|
CONSTRAINT uq_campaign_lead UNIQUE (campaign_id, lead_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS email_campaign_logs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
campaign_id UUID REFERENCES email_campaigns(id) ON DELETE CASCADE,
|
||||||
|
subscriber_id UUID REFERENCES email_campaign_subscribers(id) ON DELETE CASCADE,
|
||||||
|
step_id UUID REFERENCES email_campaign_steps(id) ON DELETE SET NULL,
|
||||||
|
recipient VARCHAR(255) NOT NULL,
|
||||||
|
subject VARCHAR(500),
|
||||||
|
body_text TEXT,
|
||||||
|
sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'sent',
|
||||||
|
error_message TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Already defined inline in CREATE TABLE; IF NOT EXISTS not supported on this PostgreSQL version
|
||||||
|
-- ALTER TABLE email_campaign_subscribers ADD CONSTRAINT IF NOT EXISTS uq_campaign_lead UNIQUE (campaign_id, lead_id);
|
||||||
|
|
||||||
|
CREATE INDEX idx_campaign_steps_order ON email_campaign_steps(campaign_id, step_order);
|
||||||
|
CREATE INDEX idx_campaign_subscribers ON email_campaign_subscribers(campaign_id, status);
|
||||||
|
CREATE INDEX idx_campaign_logs ON email_campaign_logs(campaign_id, sent_at);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add pinned_at column to conversation_participants for pinning chats
|
||||||
|
ALTER TABLE conversation_participants ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ;
|
||||||
@@ -43,6 +43,9 @@ BEGIN;
|
|||||||
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
\echo '=== Running 011_calendar_events.sql (Calendar Events) ==='
|
||||||
\i 011_calendar_events.sql
|
\i 011_calendar_events.sql
|
||||||
|
|
||||||
|
\echo '=== Running 012_invites.sql (Invites) ==='
|
||||||
|
\i 012_invites.sql
|
||||||
|
|
||||||
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
\echo '=== Running 012_sent_emails.sql (Sent Email Log) ==='
|
||||||
\i 012_sent_emails.sql
|
\i 012_sent_emails.sql
|
||||||
|
|
||||||
@@ -63,5 +66,36 @@ BEGIN;
|
|||||||
|
|
||||||
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
|
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
|
||||||
\i 016_participant_notes.sql
|
\i 016_participant_notes.sql
|
||||||
|
|
||||||
|
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
|
||||||
|
\i 017_calendar_developer.sql
|
||||||
|
|
||||||
|
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
|
||||||
|
\i 018_website_creation_type.sql
|
||||||
|
|
||||||
|
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
|
||||||
|
\i 019_allow_null_start_time.sql
|
||||||
|
|
||||||
|
\echo '=== Running 020_projects.sql (Project Management) ==='
|
||||||
|
\i 020_projects.sql
|
||||||
|
|
||||||
|
\echo '=== Running 021_proposals_time.sql (Proposals + Time Tracking) ==='
|
||||||
|
\i 021_proposals_time.sql
|
||||||
|
|
||||||
|
\echo '=== Running 022_custom_fields.sql (Custom Fields + Workflows) ==='
|
||||||
|
\i 022_custom_fields.sql
|
||||||
|
|
||||||
|
\echo '=== Running 023_client_portal.sql (Client Portal) ==='
|
||||||
|
\i ../../src/app/client-portal/database/020_client_portal.sql
|
||||||
|
|
||||||
|
\echo '=== Running 024_resource_planning.sql (Resource Planning) ==='
|
||||||
|
\i 024_resource_planning.sql
|
||||||
|
|
||||||
|
\echo '=== Running 025_lead_scoring.sql (Lead Scoring ML) ==='
|
||||||
|
\i 025_lead_scoring.sql
|
||||||
|
|
||||||
|
\echo '=== Running 026_email_campaigns.sql (Email Campaigns) ==='
|
||||||
|
\i 026_email_campaigns.sql
|
||||||
|
|
||||||
\echo '=== Migration Complete ==='
|
\echo '=== Migration Complete ==='
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
+4
-1
@@ -2,7 +2,7 @@ import type { NextConfig } from "next"
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: false,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
experimental: {
|
||||||
|
optimizePackageImports: ["lucide-react", "framer-motion", "date-fns", "@radix-ui/react-icons", "recharts"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default nextConfig
|
export default nextConfig
|
||||||
|
|||||||
Generated
+390
-7
@@ -8,6 +8,9 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"build": "^0.1.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devenv": "^1.0.1",
|
"devenv": "^1.0.1",
|
||||||
@@ -100,6 +104,14 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@colors/colors": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.90"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@csstools/color-helpers": {
|
"node_modules/@csstools/color-helpers": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||||
@@ -210,6 +222,66 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dabh/diagnostics": {
|
||||||
|
"version": "2.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
|
||||||
|
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"@so-ric/colorspace": "^1.1.6",
|
||||||
|
"enabled": "2.0.x",
|
||||||
|
"kuler": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/core": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/accessibility": "^3.1.1",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0",
|
||||||
|
"react-dom": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/sortable": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.0",
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dnd-kit/utilities": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@emnapi/core": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||||
@@ -1992,6 +2064,57 @@
|
|||||||
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
"integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@so-ric/colorspace": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
|
||||||
|
"dependencies": {
|
||||||
|
"color": "^5.0.2",
|
||||||
|
"text-hex": "1.0.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@so-ric/colorspace/node_modules/color": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^3.1.3",
|
||||||
|
"color-string": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@so-ric/colorspace/node_modules/color-convert": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@so-ric/colorspace/node_modules/color-name": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@so-ric/colorspace/node_modules/color-string": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@socket.io/component-emitter": {
|
"node_modules/@socket.io/component-emitter": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
@@ -2284,6 +2407,11 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/triple-beam": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -3175,8 +3303,7 @@
|
|||||||
"node_modules/async": {
|
"node_modules/async": {
|
||||||
"version": "3.2.6",
|
"version": "3.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/async-function": {
|
"node_modules/async-function": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
@@ -3411,6 +3538,26 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/build": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/build/-/build-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-KwbDJ/zrsU8KZRRMfoURG14cKIAStUlS8D5jBDvtrZbwO5FEkYqc3oB8HIhRiyD64A48w1lc+sOmQ+mmBw5U/Q==",
|
||||||
|
"dependencies": {
|
||||||
|
"cssmin": "0.3.x",
|
||||||
|
"jsmin": "1.x",
|
||||||
|
"jxLoader": "*",
|
||||||
|
"moo-server": "*",
|
||||||
|
"promised-io": "*",
|
||||||
|
"timespan": "2.x",
|
||||||
|
"uglify-js": "1.x",
|
||||||
|
"walker": "1.x",
|
||||||
|
"winston": "*",
|
||||||
|
"wrench": "1.3.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">v0.4.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
@@ -3846,6 +3993,14 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cssmin": {
|
||||||
|
"version": "0.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cssmin/-/cssmin-0.3.2.tgz",
|
||||||
|
"integrity": "sha512-bynxGIAJ8ybrnFobjsQotIjA8HFDDgPwbeUWNXXXfR+B4f9kkxdcUyagJoQCSUOfMV+ZZ6bMn8bvbozlCzUGwQ==",
|
||||||
|
"bin": {
|
||||||
|
"cssmin": "bin/cssmin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssstyle": {
|
"node_modules/cssstyle": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
||||||
@@ -4255,6 +4410,11 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/enabled": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="
|
||||||
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||||
@@ -5041,6 +5201,11 @@
|
|||||||
"reusify": "^1.0.4"
|
"reusify": "^1.0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fecha": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="
|
||||||
|
},
|
||||||
"node_modules/file-entry-cache": {
|
"node_modules/file-entry-cache": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
|
||||||
@@ -5133,6 +5298,11 @@
|
|||||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/fn.name": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -5635,8 +5805,7 @@
|
|||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/internal-slot": {
|
"node_modules/internal-slot": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
@@ -5999,6 +6168,17 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-stream": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-string": {
|
"node_modules/is-string": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
|
||||||
@@ -6204,6 +6384,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsmin": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsmin/-/jsmin-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-OPuL5X/bFKgVdMvEIX3hnpx3jbVpFCrEM8pKPXjFkZUqg521r41ijdyTz7vACOhW6o1neVlcLyd+wkbK5fNHRg==",
|
||||||
|
"bin": {
|
||||||
|
"jsmin": "bin/jsmin"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.93"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -6249,6 +6440,28 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jxLoader": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jxLoader/-/jxLoader-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-ClEvAj3K68y8uKhub3RgTmcRPo5DfIWvtxqrKQdDPyZ1UVHIIKvVvjrAsJFSVL5wjv0rt5iH9SMCZ0XRKNzeUA==",
|
||||||
|
"dependencies": {
|
||||||
|
"js-yaml": "0.3.x",
|
||||||
|
"moo-server": "1.3.x",
|
||||||
|
"promised-io": "*",
|
||||||
|
"walker": "1.x"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">v0.4.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jxLoader/node_modules/js-yaml": {
|
||||||
|
"version": "0.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-0.3.7.tgz",
|
||||||
|
"integrity": "sha512-/7PsVDNP2tVe2Z1cF9kTEkjamIwz4aooDpRKmN1+g/9eePCgcxsv4QDvEbxO0EH+gdDD7MLyDoR6BASo3hH51g==",
|
||||||
|
"engines": {
|
||||||
|
"node": "> 0.4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -6258,6 +6471,11 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kuler": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
|
||||||
|
},
|
||||||
"node_modules/language-subtag-registry": {
|
"node_modules/language-subtag-registry": {
|
||||||
"version": "0.3.23",
|
"version": "0.3.23",
|
||||||
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
"resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz",
|
||||||
@@ -6333,6 +6551,22 @@
|
|||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/logform": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/logform/-/logform-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@colors/colors": "1.6.0",
|
||||||
|
"@types/triple-beam": "^1.3.2",
|
||||||
|
"fecha": "^4.2.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"triple-beam": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -6405,6 +6639,14 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/makeerror": {
|
||||||
|
"version": "1.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
|
||||||
|
"integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
|
||||||
|
"dependencies": {
|
||||||
|
"tmpl": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -6524,6 +6766,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/moo-server": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/moo-server/-/moo-server-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-9A8/eor2DXwpv1+a4pZAAydqLFVrWoKoO1fzdzqLUhYVXAO1Kgd1FR2gFZi7YdHzF0s4W8cDNwCfKJQrvLqxDw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">v0.4.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/motion-dom": {
|
"node_modules/motion-dom": {
|
||||||
"version": "11.18.1",
|
"version": "11.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
|
||||||
@@ -6892,6 +7142,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/one-time": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/one-time/-/one-time-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==",
|
||||||
|
"dependencies": {
|
||||||
|
"fn.name": "1.x.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/optionator": {
|
"node_modules/optionator": {
|
||||||
"version": "0.9.4",
|
"version": "0.9.4",
|
||||||
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
|
||||||
@@ -7363,6 +7621,11 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/promised-io": {
|
||||||
|
"version": "0.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/promised-io/-/promised-io-0.3.6.tgz",
|
||||||
|
"integrity": "sha512-bNwZusuNIW4m0SPR8jooSyndD35ggirHlxVl/UhIaZD/F0OBv9ebfc6tNmbpZts3QXHggkjIBH8lvtnzhtcz0A=="
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -7641,6 +7904,19 @@
|
|||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "3.6.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"string_decoder": "^1.1.1",
|
||||||
|
"util-deprecate": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -7850,7 +8126,6 @@
|
|||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -7899,6 +8174,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
@@ -8306,6 +8589,14 @@
|
|||||||
"integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
|
"integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/stack-trace": {
|
||||||
|
"version": "0.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||||
|
"integrity": "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -8336,6 +8627,14 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "7.2.0",
|
"version": "7.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||||
@@ -8675,6 +8974,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-hex": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
@@ -8696,6 +9000,14 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/timespan": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/timespan/-/timespan-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-0Jq9+58T2wbOyLth0EU+AUb6JMGCLaTWIykJFa7hyAybjVH9gpVMTfUAwo5fWAvtFt2Tjh/Elg8JtgNpnMnM8g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-invariant": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
@@ -8746,6 +9058,11 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tmpl": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -8804,6 +9121,14 @@
|
|||||||
"tree-kill": "cli.js"
|
"tree-kill": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/triple-beam": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -8951,6 +9276,14 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uglify-js": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-YPX1DjKtom8l9XslmPFQnqWzTBkvI4N0pbkzLuPZZ4QTyig0uQqvZz9NgUdfEV+qccJzi7fVcGWdESvRIjWptQ==",
|
||||||
|
"bin": {
|
||||||
|
"uglifyjs": "bin/uglifyjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
@@ -9121,8 +9454,7 @@
|
|||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
@@ -9205,6 +9537,14 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/walker": {
|
||||||
|
"version": "1.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||||
|
"integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"makeerror": "1.0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||||
@@ -9367,6 +9707,40 @@
|
|||||||
"integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==",
|
"integrity": "sha512-XBNxKIMLO6uVHf1Xvo++HGWAZZoiVCHmEMCmZJzJ82vQsuUJCLw13Gzq0mRCATk7a3+ZcgeOKSDioavuYqtlfA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/winston": {
|
||||||
|
"version": "3.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||||
|
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@colors/colors": "^1.6.0",
|
||||||
|
"@dabh/diagnostics": "^2.0.8",
|
||||||
|
"async": "^3.2.3",
|
||||||
|
"is-stream": "^2.0.0",
|
||||||
|
"logform": "^2.7.0",
|
||||||
|
"one-time": "^1.0.0",
|
||||||
|
"readable-stream": "^3.4.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"stack-trace": "0.0.x",
|
||||||
|
"triple-beam": "^1.3.0",
|
||||||
|
"winston-transport": "^4.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/winston-transport": {
|
||||||
|
"version": "4.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/winston-transport/-/winston-transport-4.9.0.tgz",
|
||||||
|
"integrity": "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==",
|
||||||
|
"dependencies": {
|
||||||
|
"logform": "^2.7.0",
|
||||||
|
"readable-stream": "^3.6.2",
|
||||||
|
"triple-beam": "^1.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -9407,6 +9781,15 @@
|
|||||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrench": {
|
||||||
|
"version": "1.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrench/-/wrench-1.3.9.tgz",
|
||||||
|
"integrity": "sha512-srTJQmLTP5YtW+F5zDuqjMEZqLLr/eJOZfDI5ibfPfRMeDh3oBUefAscuH0q5wBKE339ptH/S/0D18ZkfOfmKQ==",
|
||||||
|
"deprecated": "wrench.js is deprecated! You should check out fs-extra (https://github.com/jprichardson/node-fs-extra) for any operations you were using wrench for. Thanks for all the usage over the years.",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.1.97"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.0",
|
"version": "8.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||||
|
|||||||
+13
-3
@@ -6,18 +6,27 @@
|
|||||||
"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 AI,SIGNAL,NEXT,OPEN -c cyan,yellow,green,white \"npm run dev:rust\" \"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 && node scripts/run-migrations.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": "node scripts/ensure-ollama.mjs && concurrently -n AI,NEXT -c cyan,green \"npm run dev:rust\" \"next start -p 3006\"",
|
||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
@@ -40,6 +49,7 @@
|
|||||||
"@tanstack/react-table": "^8.20.6",
|
"@tanstack/react-table": "^8.20.6",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"build": "^0.1.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"devenv": "^1.0.1",
|
"devenv": "^1.0.1",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,6 +3,13 @@ const { chromium } = require('playwright');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*');
|
||||||
|
res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Content-Type');
|
||||||
|
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
||||||
|
next();
|
||||||
|
});
|
||||||
const PORT = process.env.PORT || 3007;
|
const PORT = process.env.PORT || 3007;
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
@@ -36,19 +43,20 @@ app.post('/scrape/indeed', async (req, res) => {
|
|||||||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
const items = await page.evaluate(() => {
|
const items = await page.evaluate(() => {
|
||||||
const cards = document.querySelectorAll('.job_seen_beacon');
|
const cards = document.querySelectorAll('.job_seen_beacon, [class*="card"], [class*="result"], li');
|
||||||
return Array.from(cards).slice(0, 8).map(card => {
|
return Array.from(cards).slice(0, 10).map(card => {
|
||||||
const titleEl = card.querySelector('.jcs-JobTitle');
|
const titleEl = card.querySelector('.jcs-JobTitle, a[class*="title"], a[class*="job"], h2 a');
|
||||||
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"]');
|
const companyEl = card.querySelector('[data-testid="inlineHeader-companyName"], [class*="company"], [class*="comp"]');
|
||||||
const snippetEl = card.querySelector('.job-snippet');
|
const snippetEl = card.querySelector('.job-snippet, [class*="snippet"], [class*="summary"]');
|
||||||
return {
|
return {
|
||||||
title: titleEl?.textContent?.trim() || '',
|
title: titleEl?.textContent?.trim() || '',
|
||||||
url: titleEl?.href || '',
|
url: titleEl?.href || '',
|
||||||
company: companyEl?.textContent?.trim() || '',
|
company: companyEl?.textContent?.trim() || '',
|
||||||
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
snippet: snippetEl?.textContent?.trim()?.slice(0, 200) || '',
|
||||||
};
|
};
|
||||||
});
|
}).filter(j => j.title);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|||||||
+7
-9
@@ -558,12 +558,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -576,10 +575,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
+17
-16
@@ -23831,6 +23831,7 @@ var init_harTracer = __esm({
|
|||||||
this._pageEntries = [];
|
this._pageEntries = [];
|
||||||
this._eventListeners = [];
|
this._eventListeners = [];
|
||||||
this._started = false;
|
this._started = false;
|
||||||
|
this._omitWebSocketFrames = true;
|
||||||
this._context = context2;
|
this._context = context2;
|
||||||
this._page = page;
|
this._page = page;
|
||||||
this._delegate = delegate;
|
this._delegate = delegate;
|
||||||
@@ -23847,6 +23848,9 @@ var init_harTracer = __esm({
|
|||||||
this._pageEntrySymbol = Symbol("pageHarEntry");
|
this._pageEntrySymbol = Symbol("pageHarEntry");
|
||||||
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
|
this._baseURL = context2 instanceof APIRequestContext ? context2._defaultOptions().baseURL : context2._options.baseURL;
|
||||||
}
|
}
|
||||||
|
setOmitWebSocketFrames(omitWebSocketFrames) {
|
||||||
|
this._omitWebSocketFrames = omitWebSocketFrames;
|
||||||
|
}
|
||||||
start(options) {
|
start(options) {
|
||||||
if (this._started)
|
if (this._started)
|
||||||
return;
|
return;
|
||||||
@@ -24157,7 +24161,7 @@ var init_harTracer = __esm({
|
|||||||
harEntry._resourceType = "websocket";
|
harEntry._resourceType = "websocket";
|
||||||
let sha1 = void 0;
|
let sha1 = void 0;
|
||||||
const recordMessage = (type3, opcode, data, wallTimeMs) => {
|
const recordMessage = (type3, opcode, data, wallTimeMs) => {
|
||||||
if (this._options.omitWebSocketFrames)
|
if (this._omitWebSocketFrames)
|
||||||
return;
|
return;
|
||||||
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
|
const message = { type: type3, time: this._options.omitTiming ? -1 : wallTimeMs, opcode, data };
|
||||||
if (this._options.content === "embed") {
|
if (this._options.content === "embed") {
|
||||||
@@ -24441,9 +24445,9 @@ var init_harRecorder = __esm({
|
|||||||
includeTraceInfo: false,
|
includeTraceInfo: false,
|
||||||
recordRequestOverrides: true,
|
recordRequestOverrides: true,
|
||||||
waitForContentOnStop: true,
|
waitForContentOnStop: true,
|
||||||
urlFilter: urlFilterRe ?? options.urlGlob,
|
urlFilter: urlFilterRe ?? options.urlGlob
|
||||||
omitWebSocketFrames: !!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES
|
|
||||||
});
|
});
|
||||||
|
this._tracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_HAR_NO_WEBSOCKET_FRAMES);
|
||||||
this._tracer.start({ omitScripts: false });
|
this._tracer.start({ omitScripts: false });
|
||||||
}
|
}
|
||||||
onEntryStarted(entry) {
|
onEntryStarted(entry) {
|
||||||
@@ -24652,8 +24656,7 @@ var init_tracing = __esm({
|
|||||||
content: "attach",
|
content: "attach",
|
||||||
includeTraceInfo: true,
|
includeTraceInfo: true,
|
||||||
recordRequestOverrides: false,
|
recordRequestOverrides: false,
|
||||||
waitForContentOnStop: false,
|
waitForContentOnStop: false
|
||||||
omitWebSocketFrames: !!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES
|
|
||||||
});
|
});
|
||||||
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
|
const testIdAttributeName2 = "selectors" in context2 ? context2.selectors().testIdAttributeName() : void 0;
|
||||||
this._contextCreatedEvent = {
|
this._contextCreatedEvent = {
|
||||||
@@ -24747,6 +24750,7 @@ var init_tracing = __esm({
|
|||||||
);
|
);
|
||||||
if (this._state.options.screenshots)
|
if (this._state.options.screenshots)
|
||||||
this._startScreencast();
|
this._startScreencast();
|
||||||
|
this._harTracer.setOmitWebSocketFrames(!!process.env.PLAYWRIGHT_TRACING_NO_WEBSOCKET_FRAMES);
|
||||||
if (this._state.options.snapshots)
|
if (this._state.options.snapshots)
|
||||||
await this._snapshotter?.start(progress2);
|
await this._snapshotter?.start(progress2);
|
||||||
return { traceName: this._state.traceName };
|
return { traceName: this._state.traceName };
|
||||||
@@ -24896,6 +24900,7 @@ var init_tracing = __esm({
|
|||||||
eventsHelper.removeEventListeners(this._eventListeners);
|
eventsHelper.removeEventListeners(this._eventListeners);
|
||||||
if (this._state.options.screenshots)
|
if (this._state.options.screenshots)
|
||||||
this._stopScreencast();
|
this._stopScreencast();
|
||||||
|
this._harTracer.setOmitWebSocketFrames(true);
|
||||||
if (this._state.options.snapshots)
|
if (this._state.options.snapshots)
|
||||||
this._snapshotter?.stop();
|
this._snapshotter?.stop();
|
||||||
this.flushHarEntries();
|
this.flushHarEntries();
|
||||||
@@ -35989,7 +35994,7 @@ var init_crNetworkManager = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.wallTime * 1e3;
|
const wallTimeMs = event.wallTime * 1e3;
|
||||||
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp);
|
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
|
||||||
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, "\n"), wallTimeMs);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers, "\n"), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -35997,7 +36002,7 @@ var init_crNetworkManager = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
_maybeUpdateRequestSession(sessionInfo, request2) {
|
_maybeUpdateRequestSession(sessionInfo, request2) {
|
||||||
if (request2.session !== sessionInfo.session && !sessionInfo.isMain && (request2._documentId === request2._requestId || sessionInfo.workerFrame))
|
if (request2.session !== sessionInfo.session && !sessionInfo.isMain && (request2._documentId === request2._requestId || sessionInfo.workerFrame))
|
||||||
@@ -44350,15 +44355,11 @@ var init_ffPage = __esm({
|
|||||||
this._page.frameManager.frameAbortedNavigation(params2.frameId, params2.errorText, params2.navigationId);
|
this._page.frameManager.frameAbortedNavigation(params2.frameId, params2.errorText, params2.navigationId);
|
||||||
}
|
}
|
||||||
_onNavigationCommitted(params2) {
|
_onNavigationCommitted(params2) {
|
||||||
if (!params2.navigationId) {
|
|
||||||
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [workerId, worker] of this._workers) {
|
for (const [workerId, worker] of this._workers) {
|
||||||
if (worker.frameId === params2.frameId)
|
if (worker.frameId === params2.frameId)
|
||||||
this._onWorkerDestroyed({ workerId });
|
this._onWorkerDestroyed({ workerId });
|
||||||
}
|
}
|
||||||
this._page.frameManager.frameCommittedNewDocumentNavigation(params2.frameId, params2.url, params2.name || "", params2.navigationId, false);
|
this._page.frameManager.frameCommittedNewDocumentNavigation(params2.frameId, params2.url, params2.name || "", params2.navigationId || "", false);
|
||||||
}
|
}
|
||||||
_onSameDocumentNavigation(params2) {
|
_onSameDocumentNavigation(params2) {
|
||||||
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
this._page.frameManager.frameCommittedSameDocumentNavigation(params2.frameId, params2.url);
|
||||||
@@ -46941,7 +46942,7 @@ var init_wkPage = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.walltime * 1e3;
|
const wallTimeMs = event.walltime * 1e3;
|
||||||
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp);
|
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
|
||||||
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -46949,7 +46950,7 @@ var init_wkPage = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
async _grantPermissions(origin, permissions) {
|
async _grantPermissions(origin, permissions) {
|
||||||
const webPermissionToProtocol = /* @__PURE__ */ new Map([
|
const webPermissionToProtocol = /* @__PURE__ */ new Map([
|
||||||
@@ -49287,7 +49288,7 @@ var init_wvPage = __esm({
|
|||||||
}
|
}
|
||||||
_onWebSocketWillSendHandshakeRequest(event) {
|
_onWebSocketWillSendHandshakeRequest(event) {
|
||||||
const wallTimeMs = event.walltime * 1e3;
|
const wallTimeMs = event.walltime * 1e3;
|
||||||
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp);
|
this._timestampBaselineForWebSocket.set(event.requestId, wallTimeMs - event.timestamp * 1e3);
|
||||||
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
this._page.frameManager.onWebSocketRequest(event.requestId, headersObjectToArray(event.request.headers), wallTimeMs);
|
||||||
}
|
}
|
||||||
_onWebSocketClosed(event) {
|
_onWebSocketClosed(event) {
|
||||||
@@ -49295,7 +49296,7 @@ var init_wvPage = __esm({
|
|||||||
this._page.frameManager.webSocketClosed(event.requestId);
|
this._page.frameManager.webSocketClosed(event.requestId);
|
||||||
}
|
}
|
||||||
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
_timestampToWallTimeMsForWebSocket(requestId, timestamp) {
|
||||||
return this._timestampBaselineForWebSocket.get(requestId) + timestamp;
|
return this._timestampBaselineForWebSocket.get(requestId) + timestamp * 1e3;
|
||||||
}
|
}
|
||||||
shouldToggleStyleSheetToSyncAnimations() {
|
shouldToggleStyleSheetToSyncAnimations() {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
Generated
Vendored
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
|
<link rel="icon" href="./playwright-logo.svg" type="image/svg+xml">
|
||||||
<link rel="manifest" href="./manifest.webmanifest">
|
<link rel="manifest" href="./manifest.webmanifest">
|
||||||
<title>Playwright Trace Viewer</title>
|
<title>Playwright Trace Viewer</title>
|
||||||
<script type="module" crossorigin src="./index.DEBl1tfz.js"></script>
|
<script type="module" crossorigin src="./index.DMMX1gXU.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="./assets/urlMatch-BYQrIQwR.js">
|
<link rel="modulepreload" crossorigin href="./assets/urlMatch-BYQrIQwR.js">
|
||||||
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js">
|
<link rel="modulepreload" crossorigin href="./assets/defaultSettingsView-BNmKHKpQ.js">
|
||||||
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
|
<link rel="stylesheet" crossorigin href="./defaultSettingsView.CjdS-WJx.css">
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "playwright-core",
|
"name": "playwright-core",
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"description": "A high-level API to automate web browsers",
|
"description": "A high-level API to automate web browsers",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
+3
-2
@@ -869,7 +869,7 @@ function resolve(specifier, context, nextResolve) {
|
|||||||
const filename = import_url.default.fileURLToPath(context.parentURL);
|
const filename = import_url.default.fileURLToPath(context.parentURL);
|
||||||
const resolved = resolveHook(filename, specifier);
|
const resolved = resolveHook(filename, specifier);
|
||||||
if (resolved !== void 0) {
|
if (resolved !== void 0) {
|
||||||
specifier = context.conditions?.includes("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
|
specifier = new Set(context.conditions).has("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result2 = nextResolve(specifier, context);
|
const result2 = nextResolve(specifier, context);
|
||||||
@@ -894,7 +894,8 @@ function load(moduleUrl, context, nextLoad) {
|
|||||||
const filename = import_url.default.fileURLToPath(moduleUrl);
|
const filename = import_url.default.fileURLToPath(moduleUrl);
|
||||||
if (!shouldTransform(filename))
|
if (!shouldTransform(filename))
|
||||||
return nextLoad(moduleUrl, context);
|
return nextLoad(moduleUrl, context);
|
||||||
const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
|
const isRequire = !new Set(context.conditions).has("import");
|
||||||
|
const format = isRequire ? "commonjs" : kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
|
||||||
const code = import_fs5.default.readFileSync(filename, "utf-8");
|
const code = import_fs5.default.readFileSync(filename, "utf-8");
|
||||||
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@
|
|||||||
1.2 KB packages/playwright/src/common/validators.ts
|
1.2 KB packages/playwright/src/common/validators.ts
|
||||||
1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts
|
1.3 KB packages/playwright/src/isomorphic/teleReceiver.ts
|
||||||
9.6 KB packages/playwright/src/transform/compilationCache.ts
|
9.6 KB packages/playwright/src/transform/compilationCache.ts
|
||||||
1.6 KB packages/playwright/src/transform/esmLoaderSync.ts
|
1.7 KB packages/playwright/src/transform/esmLoaderSync.ts
|
||||||
1.1 KB packages/playwright/src/transform/pirates.ts
|
1.1 KB packages/playwright/src/transform/pirates.ts
|
||||||
1.1 KB packages/playwright/src/transform/portTransport.ts
|
1.1 KB packages/playwright/src/transform/portTransport.ts
|
||||||
12.3 KB packages/playwright/src/transform/transform.ts
|
12.3 KB packages/playwright/src/transform/transform.ts
|
||||||
|
|||||||
+3
-1
@@ -12832,8 +12832,10 @@ function createExpect(info) {
|
|||||||
if (typeof m !== "function")
|
if (typeof m !== "function")
|
||||||
throw new TypeError(`expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "${typeof m}"`);
|
throw new TypeError(`expect.extend: \`${name}\` is not a valid matcher. Must be a function, is "${typeof m}"`);
|
||||||
}
|
}
|
||||||
Object.assign(info.userMatchers, matchers2);
|
|
||||||
for (const [name, matcher] of Object.entries(matchers2)) {
|
for (const [name, matcher] of Object.entries(matchers2)) {
|
||||||
|
if (name in allBuiltinMatchers)
|
||||||
|
continue;
|
||||||
|
info.userMatchers[name] = matcher;
|
||||||
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
|
const { positive, inverse } = buildCustomAsymmetricMatcher(name, matcher);
|
||||||
expectFn[name] = positive;
|
expectFn[name] = positive;
|
||||||
notAsymmetric[name] = inverse;
|
notAsymmetric[name] = inverse;
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# packages/playwright/lib/matchers/expect.js
|
# packages/playwright/lib/matchers/expect.js
|
||||||
# total: 513.8 KB
|
# total: 513.9 KB
|
||||||
|
|
||||||
## Inlined (64)
|
## Inlined (64)
|
||||||
7.9 KB node_modules/@babel/code-frame/lib/index.js
|
7.9 KB node_modules/@babel/code-frame/lib/index.js
|
||||||
|
|||||||
+3
-2
@@ -5629,7 +5629,7 @@ function resolve(specifier, context, nextResolve) {
|
|||||||
const filename = import_url.default.fileURLToPath(context.parentURL);
|
const filename = import_url.default.fileURLToPath(context.parentURL);
|
||||||
const resolved = resolveHook(filename, specifier);
|
const resolved = resolveHook(filename, specifier);
|
||||||
if (resolved !== void 0) {
|
if (resolved !== void 0) {
|
||||||
specifier = context.conditions?.includes("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
|
specifier = new Set(context.conditions).has("import") ? import_url.default.pathToFileURL(resolved).toString() : resolved;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const result2 = nextResolve(specifier, context);
|
const result2 = nextResolve(specifier, context);
|
||||||
@@ -5654,7 +5654,8 @@ function load(moduleUrl, context, nextLoad) {
|
|||||||
const filename = import_url.default.fileURLToPath(moduleUrl);
|
const filename = import_url.default.fileURLToPath(moduleUrl);
|
||||||
if (!shouldTransform(filename))
|
if (!shouldTransform(filename))
|
||||||
return nextLoad(moduleUrl, context);
|
return nextLoad(moduleUrl, context);
|
||||||
const format = kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
|
const isRequire = !new Set(context.conditions).has("import");
|
||||||
|
const format = isRequire ? "commonjs" : kSupportedFormats.get(context.format) || (fileIsModule(filename) ? "module" : "commonjs");
|
||||||
const code = import_fs4.default.readFileSync(filename, "utf-8");
|
const code = import_fs4.default.readFileSync(filename, "utf-8");
|
||||||
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
const transformed = transformHook(code, filename, format === "module" ? moduleUrl : void 0);
|
||||||
return {
|
return {
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
# packages/playwright/lib/transform/esmLoader.js
|
# packages/playwright/lib/transform/esmLoader.js
|
||||||
# total: 249.0 KB
|
# total: 249.1 KB
|
||||||
|
|
||||||
## Inlined (47)
|
## Inlined (47)
|
||||||
1.5 KB node_modules/balanced-match/index.js
|
1.5 KB node_modules/balanced-match/index.js
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
0.2 KB packages/isomorphic/stringUtils.ts
|
0.2 KB packages/isomorphic/stringUtils.ts
|
||||||
6.3 KB packages/playwright/src/transform/compilationCache.ts
|
6.3 KB packages/playwright/src/transform/compilationCache.ts
|
||||||
3.1 KB packages/playwright/src/transform/esmLoader.ts
|
3.1 KB packages/playwright/src/transform/esmLoader.ts
|
||||||
1.6 KB packages/playwright/src/transform/esmLoaderSync.ts
|
1.7 KB packages/playwright/src/transform/esmLoaderSync.ts
|
||||||
1.1 KB packages/playwright/src/transform/pirates.ts
|
1.1 KB packages/playwright/src/transform/pirates.ts
|
||||||
1.1 KB packages/playwright/src/transform/portTransport.ts
|
1.1 KB packages/playwright/src/transform/portTransport.ts
|
||||||
11.7 KB packages/playwright/src/transform/transform.ts
|
11.7 KB packages/playwright/src/transform/transform.ts
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "playwright",
|
"name": "playwright",
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"description": "A high-level API to automate web browsers",
|
"description": "A high-level API to automate web browsers",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
|
|||||||
Generated
+8
-10
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"playwright": "^1.48.0"
|
"playwright": "^1.61.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
@@ -580,12 +580,11 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.61.0"
|
"playwright-core": "1.61.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -598,10 +597,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.61.0",
|
"version": "1.61.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"playwright": "^1.48.0"
|
"playwright": "^1.61.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -50,7 +50,11 @@ if (!isRunning()) {
|
|||||||
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
|
spawn(bin, ["serve"], { stdio: "ignore", detached: true }).unref()
|
||||||
}
|
}
|
||||||
// Give it a moment to start listening
|
// Give it a moment to start listening
|
||||||
|
if (platform() === "win32") {
|
||||||
|
execSync("powershell -Command \"Start-Sleep -Seconds 3\"", { stdio: "ignore", timeout: 10000 })
|
||||||
|
} else {
|
||||||
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
execSync("sleep 3", { stdio: "ignore", timeout: 5000 })
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log("Ollama already running")
|
console.log("Ollama already running")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// ── Module Generator ─────────────────────────────────────────────────
|
||||||
|
// Generates missing internal modules from templates.
|
||||||
|
// Supports built-in templates and custom .setup-templates/ directory.
|
||||||
|
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(__dirname, "..", "..");
|
||||||
|
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
|
||||||
|
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
|
||||||
|
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
|
||||||
|
|
||||||
|
export interface TemplateRegistry {
|
||||||
|
[importPath: string]: {
|
||||||
|
template: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
generator?: string; // optional custom generator function name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenerationResult {
|
||||||
|
success: boolean;
|
||||||
|
filePath: string;
|
||||||
|
internalPath: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FallbackRegistry {
|
||||||
|
[pattern: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the template registry from .setup-templates/registry.json */
|
||||||
|
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
|
||||||
|
const builtInTemplates: TemplateRegistry = {
|
||||||
|
"data/stickers": { template: "stickers.ts" },
|
||||||
|
"data/constants": { template: "constants.ts" },
|
||||||
|
"lib/utils": { template: "utils.ts" },
|
||||||
|
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
|
||||||
|
};
|
||||||
|
const builtInFallbacks: FallbackRegistry = {
|
||||||
|
"@/data/*": "data-generic.ts",
|
||||||
|
"@/lib/*": "lib-generic.ts",
|
||||||
|
"@/hooks/*": "hook-generic.ts",
|
||||||
|
"@/types/*": "types-generic.ts",
|
||||||
|
"@/components/*": "component-generic.tsx",
|
||||||
|
"@/utils/*": "utils-generic.ts",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(REGISTRY_PATH)) {
|
||||||
|
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
|
||||||
|
const custom = JSON.parse(content);
|
||||||
|
return {
|
||||||
|
templates: { ...builtInTemplates, ...(custom.templates || {}) },
|
||||||
|
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Use built-in
|
||||||
|
}
|
||||||
|
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match an internal path against fallback patterns */
|
||||||
|
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
|
||||||
|
for (const [pattern, template] of Object.entries(fallbacks)) {
|
||||||
|
// Convert glob pattern to regex
|
||||||
|
const regexStr = "^" + pattern
|
||||||
|
.replace(/\//g, "\\/")
|
||||||
|
.replace(/\./g, "\\.")
|
||||||
|
.replace(/\*/g, ".*")
|
||||||
|
.replace(/[$^+(){}[\]|]/g, "\\$&")
|
||||||
|
+ "$";
|
||||||
|
const regex = new RegExp(regexStr);
|
||||||
|
if (regex.test(internalPath)) {
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render a template with parameters */
|
||||||
|
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
|
||||||
|
let result = templateContent;
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
|
||||||
|
result = result.replace(regex, String(value));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get template content - first from custom templates dir, then built-in */
|
||||||
|
function getTemplateContent(templateName: string): string {
|
||||||
|
// Check custom templates first
|
||||||
|
const customPath = path.join(TEMPLATES_DIR, templateName);
|
||||||
|
if (fs.existsSync(customPath)) {
|
||||||
|
return fs.readFileSync(customPath, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Built-in templates (embedded)
|
||||||
|
const builtInTemplates: Record<string, string> = {
|
||||||
|
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
|
||||||
|
|
||||||
|
export interface Sticker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
emoji?: string;
|
||||||
|
svg?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StickerPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
stickers: Sticker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "reactions",
|
||||||
|
name: "Reactions",
|
||||||
|
stickers: [
|
||||||
|
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
|
||||||
|
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
|
||||||
|
{ id: "heart", name: "Heart", emoji: "❤️" },
|
||||||
|
{ id: "laugh", name: "Laugh", emoji: "😂" },
|
||||||
|
{ id: "wow", name: "Wow", emoji: "😮" },
|
||||||
|
{ id: "sad", name: "Sad", emoji: "😢" },
|
||||||
|
{ id: "angry", name: "Angry", emoji: "😡" },
|
||||||
|
{ id: "clap", name: "Clap", emoji: "👏" },
|
||||||
|
{ id: "fire", name: "Fire", emoji: "🔥" },
|
||||||
|
{ id: "100", name: "100", emoji: "💯" },
|
||||||
|
{ id: "eyes", name: "Eyes", emoji: "👀" },
|
||||||
|
{ id: "pray", name: "Pray", emoji: "🙏" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "animals",
|
||||||
|
name: "Animals",
|
||||||
|
stickers: [
|
||||||
|
{ id: "cat", name: "Cat", emoji: "🐱" },
|
||||||
|
{ id: "dog", name: "Dog", emoji: "🐶" },
|
||||||
|
{ id: "panda", name: "Panda", emoji: "🐼" },
|
||||||
|
{ id: "fox", name: "Fox", emoji: "🦊" },
|
||||||
|
{ id: "bear", name: "Bear", emoji: "🐻" },
|
||||||
|
{ id: "koala", name: "Koala", emoji: "🐨" },
|
||||||
|
{ id: "tiger", name: "Tiger", emoji: "🐯" },
|
||||||
|
{ id: "lion", name: "Lion", emoji: "🦁" },
|
||||||
|
{ id: "monkey", name: "Monkey", emoji: "🐵" },
|
||||||
|
{ id: "frog", name: "Frog", emoji: "🐸" },
|
||||||
|
{ id: "penguin", name: "Penguin", emoji: "🐧" },
|
||||||
|
{ id: "bird", name: "Bird", emoji: "🐦" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "food",
|
||||||
|
name: "Food & Drink",
|
||||||
|
stickers: [
|
||||||
|
{ id: "pizza", name: "Pizza", emoji: "🍕" },
|
||||||
|
{ id: "burger", name: "Burger", emoji: "🍔" },
|
||||||
|
{ id: "taco", name: "Taco", emoji: "🌮" },
|
||||||
|
{ id: "sushi", name: "Sushi", emoji: "🍣" },
|
||||||
|
{ id: "ramen", name: "Ramen", emoji: "🍜" },
|
||||||
|
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
|
||||||
|
{ id: "cake", name: "Cake", emoji: "🎂" },
|
||||||
|
{ id: "coffee", name: "Coffee", emoji: "☕" },
|
||||||
|
{ id: "beer", name: "Beer", emoji: "🍺" },
|
||||||
|
{ id: "wine", name: "Wine", emoji: "🍷" },
|
||||||
|
{ id: "donut", name: "Donut", emoji: "🍩" },
|
||||||
|
{ id: "cookie", name: "Cookie", emoji: "🍪" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStickerPacks(): StickerPack[] {
|
||||||
|
return [
|
||||||
|
// reactions, animals, food packs defined above
|
||||||
|
// (inline for brevity - full packs in template file)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
export const APP_NAME = "CoastIT CRM";
|
||||||
|
export const APP_VERSION = "1.0.0";
|
||||||
|
|
||||||
|
export const LEAD_STATUSES = {
|
||||||
|
new: { label: "New", color: "bg-blue-500" },
|
||||||
|
contacted: { label: "Contacted", color: "bg-yellow-500" },
|
||||||
|
qualified: { label: "Qualified", color: "bg-purple-500" },
|
||||||
|
proposal: { label: "Proposal", color: "bg-orange-500" },
|
||||||
|
won: { label: "Won", color: "bg-green-500" },
|
||||||
|
lost: { label: "Lost", color: "bg-red-500" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const USER_ROLES = {
|
||||||
|
super_admin: { label: "Super Admin", level: 1 },
|
||||||
|
admin: { label: "Admin", level: 2 },
|
||||||
|
sales: { label: "Sales", level: 3 },
|
||||||
|
dev: { label: "Developer", level: 4 },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"meeting",
|
||||||
|
"call",
|
||||||
|
"email",
|
||||||
|
"task",
|
||||||
|
"note",
|
||||||
|
] as const;
|
||||||
|
`,
|
||||||
|
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(date: Date | string): string {
|
||||||
|
const d = typeof date === "string" ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency = "USD"): string {
|
||||||
|
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncate(str: string, length: number): string {
|
||||||
|
if (str.length <= length) return str;
|
||||||
|
return str.slice(0, length - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: unknown[]) => unknown>(
|
||||||
|
fn: T,
|
||||||
|
delay: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => fn(...args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
|
||||||
|
// Auto-generated by self-healing setup.
|
||||||
|
|
||||||
|
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
role: UserRole;
|
||||||
|
active: boolean;
|
||||||
|
avatar: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Lead {
|
||||||
|
id: string;
|
||||||
|
companyName: string;
|
||||||
|
contactName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
source: string;
|
||||||
|
description: string;
|
||||||
|
status: LeadStatus;
|
||||||
|
assignedUserId: string | null;
|
||||||
|
assignedUser: User | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalLeads: number;
|
||||||
|
newLeads: number;
|
||||||
|
contactedLeads: number;
|
||||||
|
qualifiedLeads: number;
|
||||||
|
proposalLeads: number;
|
||||||
|
wonLeads: number;
|
||||||
|
lostLeads: number;
|
||||||
|
conversionRate: number;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return builtInTemplates[templateName] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a single missing module */
|
||||||
|
export function generateModule(
|
||||||
|
internalPath: string,
|
||||||
|
templates: TemplateRegistry,
|
||||||
|
fallbacks: FallbackRegistry
|
||||||
|
): GenerationResult {
|
||||||
|
let entry = templates[internalPath];
|
||||||
|
let templateFile = entry?.template;
|
||||||
|
|
||||||
|
// Try fallback patterns
|
||||||
|
if (!entry) {
|
||||||
|
const fallbackTemplate = matchFallback(internalPath, fallbacks);
|
||||||
|
if (fallbackTemplate) {
|
||||||
|
templateFile = fallbackTemplate;
|
||||||
|
entry = { template: fallbackTemplate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templateFile) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath: "",
|
||||||
|
internalPath,
|
||||||
|
error: `No template registered for @/${internalPath}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateContent = getTemplateContent(templateFile);
|
||||||
|
if (!templateContent) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath: "",
|
||||||
|
internalPath,
|
||||||
|
error: `Template not found: ${entry.template}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = renderTemplate(templateContent, entry.params || {});
|
||||||
|
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write file
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(filePath, rendered, "utf-8");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
internalPath,
|
||||||
|
message: `Generated @/${internalPath}`,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
filePath,
|
||||||
|
internalPath,
|
||||||
|
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate all missing modules for a list of internal paths */
|
||||||
|
export function generateAll(internalPaths: string[]): GenerationResult[] {
|
||||||
|
const { templates, fallbacks } = loadRegistry();
|
||||||
|
const results: GenerationResult[] = [];
|
||||||
|
|
||||||
|
for (const internalPath of internalPaths) {
|
||||||
|
const result = generateModule(internalPath, templates, fallbacks);
|
||||||
|
results.push(result);
|
||||||
|
if (result.success) {
|
||||||
|
console.log(` ✓ ${result.message}`);
|
||||||
|
} else {
|
||||||
|
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ── TypeScript Error Parser ──────────────────────────────────────────
|
||||||
|
// Parses `tsc --noEmit` or `npm run build` output to extract
|
||||||
|
// "Module not found: Can't resolve '@/path/to/module'" errors.
|
||||||
|
// Returns actionable missing module info for the generator.
|
||||||
|
|
||||||
|
export interface MissingModule {
|
||||||
|
/** The import path that failed, e.g. "@/data/stickers" */
|
||||||
|
importPath: string;
|
||||||
|
/** The file that contains the failing import */
|
||||||
|
importerFile: string;
|
||||||
|
/** Line number in importer file */
|
||||||
|
line: number;
|
||||||
|
/** Column number */
|
||||||
|
column: number;
|
||||||
|
/** Full error message for context */
|
||||||
|
message: string;
|
||||||
|
/** Whether this is an internal (@/...) module we can generate */
|
||||||
|
isInternal: boolean;
|
||||||
|
/** The normalized internal path, e.g. "data/stickers" */
|
||||||
|
internalPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse TypeScript compiler output for missing module errors */
|
||||||
|
export function parseMissingModules(tscOutput: string): MissingModule[] {
|
||||||
|
const results: MissingModule[] = [];
|
||||||
|
|
||||||
|
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
|
||||||
|
// File: src/components/chats/media-picker.tsx:10:1
|
||||||
|
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = errorRegex.exec(tscOutput)) !== null) {
|
||||||
|
const importPath = match[1];
|
||||||
|
const importerFile = match[2] || "";
|
||||||
|
const line = parseInt(match[3] || "0", 10);
|
||||||
|
const column = parseInt(match[4] || "0", 10);
|
||||||
|
|
||||||
|
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
|
||||||
|
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
|
||||||
|
let altMatch;
|
||||||
|
let altImporter = importerFile;
|
||||||
|
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
|
||||||
|
if (altMatch[1] === importPath) {
|
||||||
|
altImporter = altMatch[2];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
|
||||||
|
|
||||||
|
let internalPath: string | undefined;
|
||||||
|
if (isInternal) {
|
||||||
|
internalPath = importPath.replace(/^[@~]\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
importPath,
|
||||||
|
importerFile: altImporter,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
message: `Cannot find module '${importPath}'`,
|
||||||
|
isInternal,
|
||||||
|
internalPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by importPath + importerFile
|
||||||
|
const seen = new Set<string>();
|
||||||
|
return results.filter((m) => {
|
||||||
|
const key = `${m.importPath}|${m.importerFile}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter to only internal (@/...) modules we can auto-generate */
|
||||||
|
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
|
||||||
|
return modules.filter((m) => m.isInternal && m.internalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group missing modules by internal path */
|
||||||
|
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
|
||||||
|
const groups = new Map<string, MissingModule[]>();
|
||||||
|
for (const m of modules) {
|
||||||
|
if (!m.internalPath) continue;
|
||||||
|
const existing = groups.get(m.internalPath) || [];
|
||||||
|
existing.push(m);
|
||||||
|
groups.set(m.internalPath, existing);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
+33
-3
@@ -1,12 +1,42 @@
|
|||||||
|
// ── Dependency Check ─────────────────────────────────────────────────
|
||||||
|
// Verifies all packages listed in package.json are installed.
|
||||||
|
// Auto-runs npm install if any are missing — zero manual setup steps.
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs"
|
||||||
|
import { resolve, dirname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { platform } from "node:os"
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const root = resolve(__dirname, "..")
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8"))
|
||||||
|
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
|
||||||
|
const missing = Object.keys(allDeps).filter(name => {
|
||||||
|
try { require.resolve(name, { paths: [root] }); return false } catch { return true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.log(`\n${missing.length} package(s) missing: ${missing.join(", ")}`)
|
||||||
|
console.log("Installing...\n")
|
||||||
|
execSync("npm install --no-audit --no-fund", { cwd: root, stdio: "inherit", timeout: 120000 })
|
||||||
|
console.log("Dependencies installed\n")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If package.json read or resolution fails, skip silently
|
||||||
|
}
|
||||||
|
|
||||||
// ── Port Precheck ──────────────────────────────────────────────────
|
// ── Port Precheck ──────────────────────────────────────────────────
|
||||||
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
|
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
|
||||||
// These are the AI server, Next.js frontend, Signaling server, and
|
// These are the AI server, Next.js frontend, Signaling server, and
|
||||||
// Python scraper respectively.
|
// Python scraper respectively.
|
||||||
// Runs before anything else starts to avoid EADDRINUSE errors.
|
// Runs before anything else starts to avoid EADDRINUSE errors.
|
||||||
|
|
||||||
import { execSync } from "node:child_process"
|
|
||||||
import { platform } from "node:os"
|
|
||||||
|
|
||||||
const PORTS = [3001, 3006, 3007, 3008]
|
const PORTS = [3001, 3006, 3007, 3008]
|
||||||
|
|
||||||
if (platform() === "win32") {
|
if (platform() === "win32") {
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// ── Automated Database Migration Runner ──────────────────────────────
|
||||||
|
// Reads .env.local for DATABASE_URL, tracks applied migrations in
|
||||||
|
// a _migrations table, and runs unapplied .sql files via psql.
|
||||||
|
// Runs automatically on npm run dev and npm run setup.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import { readFileSync, existsSync } from "node:fs"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { resolve, dirname } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { createRequire } from "node:module"
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const ROOT = resolve(__dirname, "..")
|
||||||
|
|
||||||
|
// ── Load .env.local into process.env ─────────────────────────────────
|
||||||
|
|
||||||
|
const envPath = resolve(ROOT, ".env.local")
|
||||||
|
if (existsSync(envPath)) {
|
||||||
|
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed || trimmed.startsWith("#")) continue
|
||||||
|
const eq = trimmed.indexOf("=")
|
||||||
|
if (eq === -1) continue
|
||||||
|
const key = trimmed.slice(0, eq).trim()
|
||||||
|
let value = trimmed.slice(eq + 1).trim()
|
||||||
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
||||||
|
value = value.slice(1, -1)
|
||||||
|
}
|
||||||
|
if (!process.env[key]) process.env[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.log(" ~ DATABASE_URL not set — skipping migrations")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Find psql ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function findPsql() {
|
||||||
|
try {
|
||||||
|
execSync("psql --version", { stdio: "pipe", timeout: 5000 })
|
||||||
|
return "psql"
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
"C:\\Program Files\\PostgreSQL\\16\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\17\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\15\\bin\\psql.exe",
|
||||||
|
"C:\\Program Files\\PostgreSQL\\14\\bin\\psql.exe",
|
||||||
|
]
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (existsSync(p)) return `"${p}"`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const PSQL = findPsql()
|
||||||
|
if (!PSQL) {
|
||||||
|
console.log(" ~ psql not found — skipping migrations (install PostgreSQL client tools)")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Database connection (for tracking table) ─────────────────────────
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const { Pool } = require("pg")
|
||||||
|
|
||||||
|
let pool
|
||||||
|
try {
|
||||||
|
pool = new Pool({
|
||||||
|
connectionString: DATABASE_URL,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 5000,
|
||||||
|
idleTimeoutMillis: 10000,
|
||||||
|
})
|
||||||
|
await pool.query("SELECT 1")
|
||||||
|
} catch {
|
||||||
|
console.log(" ~ Database not available — skipping migrations")
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Migration logic ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
// 1. Create tracking table if not exists
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS _migrations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
// 2. Determine file order from run_all.sql (authoritative)
|
||||||
|
const runAllPath = resolve(ROOT, "database", "migrations", "run_all.sql")
|
||||||
|
const runAllContent = readFileSync(runAllPath, "utf-8")
|
||||||
|
const fileOrder = []
|
||||||
|
for (const line of runAllContent.split("\n")) {
|
||||||
|
const match = line.match(/\\i\s+(\S+\.sql)/)
|
||||||
|
if (match) fileOrder.push(match[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileOrder.length === 0) {
|
||||||
|
console.log(" ~ No migration files found in run_all.sql")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get already-applied files
|
||||||
|
let appliedSet
|
||||||
|
try {
|
||||||
|
const { rows: applied } = await pool.query("SELECT filename FROM _migrations ORDER BY filename")
|
||||||
|
appliedSet = new Set(applied.map((r) => r.filename))
|
||||||
|
} catch {
|
||||||
|
appliedSet = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Build psql command base
|
||||||
|
const psqlCmd = `${PSQL} -d "${DATABASE_URL}" -v ON_ERROR_STOP=1`
|
||||||
|
|
||||||
|
// 5. Run unapplied files in order.
|
||||||
|
// psql -v ON_ERROR_STOP=1 aborts on the first error with exit code 3.
|
||||||
|
// If the error is "already exists", the file was already applied before
|
||||||
|
// tracking existed — mark it as applied and continue.
|
||||||
|
// Other errors are genuine failures — abort.
|
||||||
|
const ALREADY_APPLIED_PATTERNS = [
|
||||||
|
"already exists",
|
||||||
|
"duplicate key",
|
||||||
|
"cannot insert multiple commands into a prepared statement",
|
||||||
|
]
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
for (const file of fileOrder) {
|
||||||
|
if (appliedSet.has(file)) continue
|
||||||
|
|
||||||
|
const filePath = resolve(ROOT, "database", "migrations", file)
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
console.error(` ✗ Migration file not found: ${file}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stderr = ""
|
||||||
|
try {
|
||||||
|
const result = execSync(`${psqlCmd} -f "${filePath}"`, {
|
||||||
|
timeout: 30000,
|
||||||
|
encoding: "utf-8",
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
})
|
||||||
|
// psql outputs notices/warnings to stderr even on success
|
||||||
|
stderr = result.stderr || ""
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.stderr || err.stdout || err.message
|
||||||
|
const isAlreadyApplied = ALREADY_APPLIED_PATTERNS.some((p) => msg.includes(p))
|
||||||
|
if (isAlreadyApplied) {
|
||||||
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1) ON CONFLICT DO NOTHING", [file])
|
||||||
|
console.log(` ~ Already applied: ${file}`)
|
||||||
|
count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
console.error(` ✗ Migration failed: ${file}`)
|
||||||
|
console.error(` ${msg.split("\n").slice(0, 5).join("\n ")}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query("INSERT INTO _migrations (filename) VALUES ($1)", [file])
|
||||||
|
console.log(` ✓ Applied: ${file}`)
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
console.log(" ~ All migrations up to date")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runMigrations()
|
||||||
|
} finally {
|
||||||
|
await pool.end()
|
||||||
|
}
|
||||||
+204
-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,225 @@ 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. Database migrations (auto-applies on fresh install)
|
||||||
console.log("\n── Next steps ──")
|
console.log("\n── Running Database Migrations ──")
|
||||||
|
try {
|
||||||
|
execSync("node scripts/run-migrations.mjs", { stdio: "inherit", timeout: 30000, cwd: ROOT })
|
||||||
|
console.log(" ✓ Migrations complete")
|
||||||
|
} catch {
|
||||||
|
console.log(" ~ Database not available — run migrations later with: npm run dev")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Self-healing build check (--self-heal flag)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 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,286 @@
|
|||||||
"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, type AIChatHandle } from "@/components/ai/ai-chat"
|
||||||
import { JobSelector } from "@/components/ai/job-selector"
|
import { JobSelector } from "@/components/ai/job-selector"
|
||||||
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
|
import { Bot, ChevronRight, Wifi, WifiOff } 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 [aiOnline, setAiOnline] = useState<boolean | null>(null)
|
||||||
|
const [searching, setSearching] = useState(false)
|
||||||
|
const [msgCount, setMsgCount] = useState(0)
|
||||||
|
const [tokenEstimate, setTokenEstimate] = useState("0")
|
||||||
|
const aiChatRef = useRef<AIChatHandle | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const saved = localStorage.getItem("ai-chat-messages")
|
||||||
|
if (saved) {
|
||||||
|
try {
|
||||||
|
const msgs = JSON.parse(saved)
|
||||||
|
if (Array.isArray(msgs)) {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
const todayMsgs = msgs.filter((m: any) => {
|
||||||
|
if (!m.ts) return true
|
||||||
|
return new Date(m.ts).getTime() >= today.getTime()
|
||||||
|
})
|
||||||
|
const userMsgs = todayMsgs.filter((m: any) => m.role === "user")
|
||||||
|
setMsgCount(userMsgs.length)
|
||||||
|
const totalChars = todayMsgs.reduce((s: number, m: any) => s + (m.content?.length || 0), 0)
|
||||||
|
const tokens = Math.round(totalChars / 4)
|
||||||
|
setTokenEstimate(tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
const handleJobSelect = useCallback((job: typeof selectedJob) => {
|
||||||
setSelectedJob(job)
|
setSelectedJob(job)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/ai/jobs")
|
||||||
|
if (!cancelled) setAiOnline(res.ok)
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setAiOnline(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
const id = setInterval(check, 15000)
|
||||||
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSearch = useCallback(async (job: NonNullable<typeof selectedJob>) => {
|
||||||
|
setSearching(true)
|
||||||
|
const prompt = `You are a lead generation assistant. Generate 5 realistic prospect profiles for my business targeting ${job.job_title} clients (${job.industry}). ${job.description ? "Service: " + job.description : ""} Keywords: ${job.keywords.join(", ")}.
|
||||||
|
|
||||||
|
IMPORTANT: Return ONLY a valid JSON array. Do NOT use markdown, code blocks, or any formatting. Just raw JSON.
|
||||||
|
|
||||||
|
Example format:
|
||||||
|
[{"companyName":"Acme Corp","contactName":"John Smith","email":"john@acme.com","phone":"555-0100","description":"Brief description"}]
|
||||||
|
|
||||||
|
Use realistic company names, names, and emails for the ${job.industry} industry.`
|
||||||
|
|
||||||
|
const addMsg = (text: string) => aiChatRef.current?.addAssistantMessage(text)
|
||||||
|
|
||||||
|
try {
|
||||||
|
addMsg(`🔍 Step 1/3: Asking AI to generate leads for **${job.job_title}**...`)
|
||||||
|
|
||||||
|
const res = await fetch("/api/ai/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: prompt }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
const statusInfo = `HTTP ${res.status}: ${data.error || res.statusText}`
|
||||||
|
addMsg(`❌ Step 1/3 failed — AI chat returned ${statusInfo}. Make sure Ollama is running (port 11434) and the AI service on port 3001 is up.`)
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
const raw = data.response || ""
|
||||||
|
if (!raw) {
|
||||||
|
addMsg("❌ Step 1/3 failed — AI returned an empty response.")
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addMsg(`✅ Step 1/3 complete. Parsing AI response...`)
|
||||||
|
|
||||||
|
let leads: any[]
|
||||||
|
try {
|
||||||
|
let text = raw.trim()
|
||||||
|
const jsonMatch = text.match(/\[[\s\S]*\]/)
|
||||||
|
if (jsonMatch) text = jsonMatch[0]
|
||||||
|
leads = JSON.parse(text)
|
||||||
|
if (!Array.isArray(leads)) leads = [leads]
|
||||||
|
} catch (e) {
|
||||||
|
addMsg(`⚠️ Step 2/3 failed — AI response wasn't valid JSON. Raw output:\n\n${raw.slice(0, 1500)}`)
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (leads.length === 0) {
|
||||||
|
addMsg("⚠️ Step 2/3 — AI returned an empty array. No leads to create.")
|
||||||
|
setSearching(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addMsg(`✅ Step 2/3 — Parsed **${leads.length}** lead${leads.length > 1 ? "s" : ""} from AI response. Step 3/3: Saving to database...`)
|
||||||
|
|
||||||
|
let created = 0
|
||||||
|
let lastError = ""
|
||||||
|
for (const lead of leads.slice(0, 5)) {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/leads", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
companyName: lead.companyName || "Unknown Company",
|
||||||
|
contactName: lead.contactName || "",
|
||||||
|
email: lead.email || "",
|
||||||
|
phone: lead.phone || "",
|
||||||
|
description: lead.description || "",
|
||||||
|
status: "open",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (r.ok) created++
|
||||||
|
else { const e = await r.json().catch(() => ({})); lastError = `API ${res.status} ${e.error || r.statusText}`; }
|
||||||
|
} catch (e) {
|
||||||
|
lastError = `Network error: ${e instanceof Error ? e.message : "Failed to reach server"}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created > 0) {
|
||||||
|
addMsg(`✅ Created **${created}** lead${created > 1 ? "s" : ""} for **${job.job_title}**! Go to the **Leads** tab to view and manage them.${lastError ? `\n\n⚠️ ${leads.length - created} lead${leads.length - created > 1 ? "s" : ""} failed: ${lastError}` : ""}`)
|
||||||
|
} else {
|
||||||
|
addMsg(`❌ Step 3/3 failed — Could not save any leads.\n\nLast error: ${lastError || "Unknown"}\n\nPossible causes:\n• Your session expired — try logging out and back in\n• You don't have permission to create leads\n• The database connection is down`)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Unknown error"
|
||||||
|
addMsg(`❌ Unexpected error: ${msg}\n\nThis might be a network issue or the AI service (port 3001) is not responding. Try again.`)
|
||||||
|
} finally {
|
||||||
|
setSearching(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
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
|
||||||
|
})
|
||||||
|
setMsgCount((prev) => prev + 1)
|
||||||
|
setTokenEstimate((prev) => {
|
||||||
|
const raw = parseInt(prev.replace("k", "")) * (prev.includes("k") ? 1000 : 1)
|
||||||
|
const updated = raw + Math.round(msg.length / 4)
|
||||||
|
return updated >= 1000 ? `${(updated / 1000).toFixed(1)}k` : String(updated)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-3.5rem)]">
|
<div className="flex h-[calc(100vh-3.5rem)] bg-background">
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0 border border-foreground transition-colors overflow-hidden">
|
||||||
<div className="border-b border-[#2a2a35] px-6 py-4">
|
<div className="bg-card/80 backdrop-blur-md border-b border-border px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
|
<div className="flex items-center gap-4">
|
||||||
<Bot className="h-5 w-5 text-[#1BB0CE]" />
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-primary to-primary flex items-center justify-center" style={{boxShadow: "0 0 40px hsl(var(--primary) / 0.3)"}}>
|
||||||
|
<Bot className="h-5 w-5 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
|
<h1 className="text-foreground font-bold text-lg">AI Sales Assistant</h1>
|
||||||
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
|
<p className="text-muted-foreground text-xs mt-0.5">Powered by local AI</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<div className="flex-1 flex">
|
<span className={aiOnline === null ? "text-[#6a6a75]" : aiOnline ? "text-green-400" : "text-red-400"}>
|
||||||
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
|
{aiOnline === null ? "Checking..." : aiOnline ? "Connected" : "Disconnected"}
|
||||||
<AIChat />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
|
||||||
<Target className="h-3.5 w-3.5" />
|
|
||||||
Target Job
|
|
||||||
</h3>
|
|
||||||
<JobSelector onSelect={handleJobSelect} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedJob && (
|
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
|
||||||
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{selectedJob.keywords.map((kw, i) => (
|
|
||||||
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
|
|
||||||
{kw}
|
|
||||||
</span>
|
</span>
|
||||||
|
{aiOnline === null ? (
|
||||||
|
<span className="h-2 w-2 rounded-full bg-[#6a6a75]" />
|
||||||
|
) : aiOnline ? (
|
||||||
|
<Wifi className="h-3.5 w-3.5 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="h-3.5 w-3.5 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-5 bg-border" />
|
||||||
|
<div className="bg-muted/50 rounded-lg px-3 py-1.5">
|
||||||
|
<span className="text-muted-foreground text-xs">Local AI Model</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
|
||||||
|
</div>
|
||||||
|
<div className="w-[300px] flex-none bg-sidebar border border-foreground transition-colors p-5 overflow-y-auto h-full flex flex-col">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
|
||||||
|
</div>
|
||||||
|
<JobSelector onSelect={handleJobSelect} onSearch={handleSearch} searching={searching} />
|
||||||
|
{selectedJob && (
|
||||||
|
<div className="bg-card/50 border border-border rounded-xl p-3.5 mt-3 space-y-2">
|
||||||
|
<h4 className="text-sm font-semibold text-foreground">{selectedJob.job_title}</h4>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium inline-block">{selectedJob.industry}</span>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{selectedJob.description}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{selectedJob.keywords.map((kw, i) => (
|
||||||
|
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-muted/50 text-muted-foreground">{kw}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
|
<div className="mt-6">
|
||||||
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Lightbulb className="h-3.5 w-3.5" />
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
Tips
|
<span className="text-primary text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
|
||||||
</h4>
|
</div>
|
||||||
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
|
{tips.map((tip, i) => (
|
||||||
<li className="flex gap-2">
|
<div
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
key={i}
|
||||||
Ask for cold email templates for a specific job
|
onClick={() => handleTipClick(tip)}
|
||||||
</li>
|
className="flex items-start gap-2.5 bg-card/50 hover:bg-card border border-border hover:border-primary/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
|
||||||
<li className="flex gap-2">
|
>
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors duration-200 flex-shrink-0" />
|
||||||
Request objection handling tips
|
<span className="text-muted-foreground text-xs leading-5 group-hover:text-foreground transition-colors duration-200">{tip}</span>
|
||||||
</li>
|
</div>
|
||||||
<li className="flex gap-2">
|
))}
|
||||||
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
|
</div>
|
||||||
Ask for outreach strategies per industry
|
<div className="mt-6">
|
||||||
</li>
|
<div className="flex items-center gap-2 mb-3">
|
||||||
</ul>
|
<span className="w-1.5 h-1.5 rounded-full bg-primary" />
|
||||||
|
<span className="text-primary 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-card/50 rounded-xl p-3 mb-2 border border-border text-muted-foreground text-xs truncate hover:text-foreground cursor-pointer transition-colors duration-200"
|
||||||
|
>
|
||||||
|
{prompt}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground text-xs text-center py-4">Your recent prompts will appear here</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto border-t border-border pt-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Messages today</div>
|
||||||
|
<div className="text-foreground text-sm font-semibold mt-0.5">{msgCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-muted-foreground text-[10px] uppercase tracking-wide">Tokens used</div>
|
||||||
|
<div className="text-foreground text-sm font-semibold mt-0.5">{tokenEstimate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
|
|||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import {
|
import {
|
||||||
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
|
||||||
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
|
||||||
ArrowUpRight, Zap, StickyNote,
|
ArrowUpRight, Zap, StickyNote, Globe,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
const EVENT_ICONS: Record<EventType, React.ElementType> = {
|
||||||
meeting: Users,
|
|
||||||
call: Phone,
|
call: Phone,
|
||||||
chat: Video,
|
|
||||||
follow_up: Clock,
|
follow_up: Clock,
|
||||||
demo: Calendar,
|
website_creation: Globe,
|
||||||
other: Calendar,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function eventVars(type: EventType) {
|
function eventVars(type: EventType) {
|
||||||
@@ -85,14 +82,20 @@ export default function CalendarPage() {
|
|||||||
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
|
||||||
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
|
||||||
|
|
||||||
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
|
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
|
||||||
|
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
|
||||||
const [formTitle, setFormTitle] = useState("")
|
const [formTitle, setFormTitle] = useState("")
|
||||||
const [formDescription, setFormDescription] = useState("")
|
const [formDescription, setFormDescription] = useState("")
|
||||||
const [formType, setFormType] = useState<EventType>("meeting")
|
const [formType, setFormType] = useState<EventType>("website_creation")
|
||||||
const [formDate, setFormDate] = useState("")
|
const [formDate, setFormDate] = useState("")
|
||||||
const [formTime, setFormTime] = useState("")
|
const [formTime, setFormTime] = useState("")
|
||||||
const [formDuration, setFormDuration] = useState("30")
|
const [formDuration, setFormDuration] = useState("30")
|
||||||
const [formParticipantId, setFormParticipantId] = useState("")
|
const [formParticipantId, setFormParticipantId] = useState("")
|
||||||
|
const [formDeveloperId, setFormDeveloperId] = useState("")
|
||||||
|
const [formLeadId, setFormLeadId] = useState("")
|
||||||
|
const [formClientName, setFormClientName] = useState("")
|
||||||
|
const [formClientEmail, setFormClientEmail] = useState("")
|
||||||
|
const [formClientPhone, setFormClientPhone] = useState("")
|
||||||
const [formSaving, setFormSaving] = useState(false)
|
const [formSaving, setFormSaving] = useState(false)
|
||||||
const [editNotes, setEditNotes] = useState(false)
|
const [editNotes, setEditNotes] = useState(false)
|
||||||
const [notesText, setNotesText] = useState("")
|
const [notesText, setNotesText] = useState("")
|
||||||
@@ -123,7 +126,11 @@ export default function CalendarPage() {
|
|||||||
fetch("/api/event-users")
|
fetch("/api/event-users")
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setUsers(data.users || []))
|
.then((data) => setUsers(data.users || []))
|
||||||
.catch(() => {})
|
.catch((err) => console.error("Failed to fetch event users:", err))
|
||||||
|
fetch("/api/leads?limit=200")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
|
||||||
|
.catch((err) => console.error("Failed to fetch leads:", err))
|
||||||
}, [user, router])
|
}, [user, router])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,13 +139,13 @@ export default function CalendarPage() {
|
|||||||
}, [fetchMonthEvents])
|
}, [fetchMonthEvents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUpcomingEvents().catch(() => {})
|
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
|
||||||
}, [fetchUpcomingEvents])
|
}, [fetchUpcomingEvents])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
fetchMonthEvents().catch(() => {})
|
fetchMonthEvents().catch((err) => console.error("Failed to fetch month events:", err))
|
||||||
fetchUpcomingEvents().catch(() => {})
|
fetchUpcomingEvents().catch((err) => console.error("Failed to fetch upcoming events:", err))
|
||||||
}, 15000)
|
}, 15000)
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [fetchMonthEvents, fetchUpcomingEvents])
|
}, [fetchMonthEvents, fetchUpcomingEvents])
|
||||||
@@ -158,7 +165,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const getEventsForDay = useCallback((day: number) => {
|
const getEventsForDay = useCallback((day: number) => {
|
||||||
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
|
||||||
return events.filter((e) => e.startTime.startsWith(dateStr))
|
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
|
||||||
}, [events, currentYear, currentMonth])
|
}, [events, currentYear, currentMonth])
|
||||||
|
|
||||||
const isToday = (day: number) => {
|
const isToday = (day: number) => {
|
||||||
@@ -169,9 +176,14 @@ export default function CalendarPage() {
|
|||||||
setEditingEvent(null)
|
setEditingEvent(null)
|
||||||
setFormTitle("")
|
setFormTitle("")
|
||||||
setFormDescription("")
|
setFormDescription("")
|
||||||
setFormType("meeting")
|
setFormType("website_creation")
|
||||||
setFormDuration("30")
|
setFormDuration("30")
|
||||||
setFormParticipantId("")
|
setFormParticipantId("")
|
||||||
|
setFormDeveloperId("")
|
||||||
|
setFormLeadId("")
|
||||||
|
setFormClientName("")
|
||||||
|
setFormClientEmail("")
|
||||||
|
setFormClientPhone("")
|
||||||
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
|
||||||
setFormDate(d.toISOString().split("T")[0])
|
setFormDate(d.toISOString().split("T")[0])
|
||||||
setFormTime("10:00")
|
setFormTime("10:00")
|
||||||
@@ -184,37 +196,58 @@ export default function CalendarPage() {
|
|||||||
setFormDescription(event.description || "")
|
setFormDescription(event.description || "")
|
||||||
setFormType(event.eventType)
|
setFormType(event.eventType)
|
||||||
setFormDuration(String(event.durationMinutes || 30))
|
setFormDuration(String(event.durationMinutes || 30))
|
||||||
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
|
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
|
||||||
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
|
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
|
||||||
setFormParticipantId(event.participantId || "")
|
setFormParticipantId(event.participantId || "")
|
||||||
|
setFormDeveloperId(event.developerId || "")
|
||||||
|
setFormLeadId(event.leadId || "")
|
||||||
|
setFormClientName(event.clientName || "")
|
||||||
|
setFormClientEmail(event.clientEmail || "")
|
||||||
|
setFormClientPhone(event.clientPhone || "")
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formTitle.trim() || !formDate || !formTime) {
|
if (!formTitle.trim()) {
|
||||||
toast.error("Title, date, and time are required")
|
toast.error("Title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (formType !== "website_creation" && (!formDate || !formTime)) {
|
||||||
|
toast.error("Date and time are required for this event type")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setFormSaving(true)
|
setFormSaving(true)
|
||||||
try {
|
try {
|
||||||
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
const body: Record<string, unknown> = {
|
||||||
|
title: formTitle.trim(),
|
||||||
|
description: formDescription.trim() || null,
|
||||||
|
eventType: formType,
|
||||||
|
}
|
||||||
|
|
||||||
|
let startTime: string | undefined
|
||||||
|
if (formType !== "website_creation") {
|
||||||
|
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
|
||||||
let endTime = null
|
let endTime = null
|
||||||
if (formDuration) {
|
if (formDuration) {
|
||||||
const end = new Date(startTime)
|
const end = new Date(startTime)
|
||||||
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
end.setMinutes(end.getMinutes() + parseInt(formDuration))
|
||||||
endTime = end.toISOString()
|
endTime = end.toISOString()
|
||||||
}
|
}
|
||||||
|
body.startTime = startTime
|
||||||
const body: Record<string, unknown> = {
|
body.endTime = endTime
|
||||||
title: formTitle.trim(),
|
body.durationMinutes = formDuration ? parseInt(formDuration) : null
|
||||||
description: formDescription.trim() || null,
|
} else if (formDate) {
|
||||||
eventType: formType,
|
startTime = new Date(`${formDate}T00:00:00`).toISOString()
|
||||||
startTime,
|
body.startTime = startTime
|
||||||
endTime,
|
|
||||||
durationMinutes: formDuration ? parseInt(formDuration) : null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (formClientName) body.clientName = formClientName
|
||||||
|
if (formClientEmail) body.clientEmail = formClientEmail
|
||||||
|
if (formClientPhone) body.clientPhone = formClientPhone
|
||||||
if (formParticipantId) body.participantId = formParticipantId
|
if (formParticipantId) body.participantId = formParticipantId
|
||||||
|
if (formDeveloperId) body.developerId = formDeveloperId
|
||||||
|
if (formLeadId) body.leadId = formLeadId
|
||||||
|
|
||||||
let res
|
let res
|
||||||
if (editingEvent) {
|
if (editingEvent) {
|
||||||
@@ -235,11 +268,13 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (editingEvent) {
|
if (editingEvent) {
|
||||||
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
toast.success("Event updated")
|
toast.success("Event updated")
|
||||||
} else {
|
} else {
|
||||||
setEvents((prev) => [...prev, data.event])
|
setEvents((prev) => [...prev, data.event])
|
||||||
|
if (startTime) {
|
||||||
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
|
||||||
|
}
|
||||||
toast.success("Event created")
|
toast.success("Event created")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +295,7 @@ export default function CalendarPage() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Failed to update")
|
if (!res.ok) throw new Error("Failed to update")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
toast.success(`Event ${newStatus}`)
|
toast.success(`Event ${newStatus}`)
|
||||||
setDetailEvent(null)
|
setDetailEvent(null)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -279,7 +314,7 @@ export default function CalendarPage() {
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error("Failed to save")
|
if (!res.ok) throw new Error("Failed to save")
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
|
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
|
||||||
if (detailEvent && detailEvent.id === event.id) {
|
if (detailEvent && detailEvent.id === event.id) {
|
||||||
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
|
||||||
}
|
}
|
||||||
@@ -579,7 +614,7 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
{/* Create/Edit Dialog */}
|
{/* Create/Edit Dialog */}
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[520px]">
|
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
@@ -608,11 +643,28 @@ export default function CalendarPage() {
|
|||||||
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
|
||||||
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||||
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
|
||||||
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{formType === "website_creation" && (
|
||||||
|
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
|
||||||
|
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
|
||||||
|
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
|
||||||
|
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
|
||||||
@@ -621,19 +673,16 @@ export default function CalendarPage() {
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="meeting">Meeting</SelectItem>
|
<SelectItem value="website_creation">Website Creation</SelectItem>
|
||||||
<SelectItem value="call">Call</SelectItem>
|
<SelectItem value="call">Call</SelectItem>
|
||||||
<SelectItem value="chat">Video Chat</SelectItem>
|
|
||||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||||
<SelectItem value="demo">Demo</SelectItem>
|
|
||||||
<SelectItem value="other">Other</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
|
||||||
<Select value={formDuration} onValueChange={setFormDuration}>
|
<Select value={formDuration} onValueChange={setFormDuration}>
|
||||||
<SelectTrigger id="duration" className="h-10">
|
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -663,6 +712,38 @@ export default function CalendarPage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
|
||||||
|
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger id="developer" className="h-10">
|
||||||
|
<SelectValue placeholder="Select a developer…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.name} {u.role ? `(${u.role})` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
|
||||||
|
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
|
||||||
|
<SelectTrigger id="lead" className="h-10">
|
||||||
|
<SelectValue placeholder="Select a client lead…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">None</SelectItem>
|
||||||
|
{leads.map((l) => (
|
||||||
|
<SelectItem key={l.id} value={l.id}>
|
||||||
|
{l.contactName}{l.companyName ? ` — ${l.companyName}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
|
||||||
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
|
||||||
@@ -690,7 +771,7 @@ export default function CalendarPage() {
|
|||||||
{/* Event Detail Dialog */}
|
{/* Event Detail Dialog */}
|
||||||
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
|
||||||
{detailEvent && (
|
{detailEvent && (
|
||||||
<DialogContent className="sm:max-w-[460px]">
|
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
|
||||||
@@ -711,6 +792,7 @@ export default function CalendarPage() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Date / Time */}
|
{/* Date / Time */}
|
||||||
|
{detailEvent.startTime ? (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
|
||||||
@@ -727,6 +809,12 @@ export default function CalendarPage() {
|
|||||||
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
|
||||||
|
<p className="text-sm text-muted-foreground/50 italic">No scheduled time — website creation project</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Duration + Type */}
|
{/* Duration + Type */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
@@ -751,11 +839,26 @@ export default function CalendarPage() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
{detailEvent.description && (
|
{detailEvent.description && (
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
|
||||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Client details */}
|
||||||
|
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
Client Details
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
|
||||||
|
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
|
||||||
|
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Linked lead */}
|
{/* Linked lead */}
|
||||||
{detailEvent.lead && (
|
{detailEvent.lead && (
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
@@ -802,6 +905,32 @@ export default function CalendarPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Developer */}
|
||||||
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
|
||||||
|
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
|
||||||
|
</p>
|
||||||
|
{detailEvent.developer ? (
|
||||||
|
<p className="text-sm font-bold flex items-center gap-2">
|
||||||
|
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
|
||||||
|
{detailEvent.developer.name.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{detailEvent.developer.name}</span>
|
||||||
|
{detailEvent.developer.role && (
|
||||||
|
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
|
||||||
|
)}
|
||||||
|
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
|
||||||
|
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
|
||||||
|
Assigned by {detailEvent.creator?.name || "Unknown"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Participant Notes (editable by both parties) */}
|
{/* Participant Notes (editable by both parties) */}
|
||||||
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
@@ -836,9 +965,15 @@ export default function CalendarPage() {
|
|||||||
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
<DialogFooter className="flex items-center gap-2 border-t pt-4">
|
||||||
{detailEvent.status === "scheduled" && (
|
{detailEvent.status === "scheduled" && (
|
||||||
<>
|
<>
|
||||||
|
{detailEvent.developerId === user?.id ? (
|
||||||
|
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
|
||||||
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
|
||||||
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { use } from "react"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
|
import {
|
||||||
|
ArrowLeft, Plus, Trash2, Send, Play, Pause, Users, Mail, Clock,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function CampaignDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params)
|
||||||
|
const router = useRouter()
|
||||||
|
const [campaign, setCampaign] = useState<any>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [newStep, setNewStep] = useState({ subject: "", bodyText: "", delayDays: 3 })
|
||||||
|
const [addingStep, setAddingStep] = useState(false)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
|
const [leadSearch, setLeadSearch] = useState("")
|
||||||
|
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||||
|
const [subscribing, setSubscribing] = useState(false)
|
||||||
|
|
||||||
|
const fetchCampaign = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/campaigns/${id}`)
|
||||||
|
if (res.ok) setCampaign(await res.json())
|
||||||
|
} catch (e) { console.error("Failed to load campaign", e); toast.error("Failed to load campaign") }
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { fetchCampaign() }, [id])
|
||||||
|
|
||||||
|
const searchLeads = async (q: string) => {
|
||||||
|
setLeadSearch(q)
|
||||||
|
if (q.length < 2) { setSearchResults([]); return }
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/leads?search=${encodeURIComponent(q)}&limit=10`)
|
||||||
|
if (res.ok) setSearchResults(await res.json())
|
||||||
|
else console.error("Failed to search leads:", res.status, res.statusText)
|
||||||
|
} catch (err) { console.error("Failed to search leads:", err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStep = async () => {
|
||||||
|
if (!newStep.subject.trim()) return
|
||||||
|
setAddingStep(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/campaigns/${id}/steps`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(newStep),
|
||||||
|
})
|
||||||
|
setNewStep({ subject: "", bodyText: "", delayDays: 3 })
|
||||||
|
fetchCampaign()
|
||||||
|
} catch (e) { console.error("Failed to add step", e); toast.error("Failed to add step") }
|
||||||
|
setAddingStep(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeLeads = async (leadIds: string[]) => {
|
||||||
|
setSubscribing(true)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/campaigns/${id}/subscribe`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ leadIds }),
|
||||||
|
})
|
||||||
|
setLeadSearch("")
|
||||||
|
setSearchResults([])
|
||||||
|
fetchCampaign()
|
||||||
|
} catch (e) { console.error("Failed to subscribe leads", e); toast.error("Failed to subscribe leads") }
|
||||||
|
setSubscribing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendCampaign = async () => {
|
||||||
|
setSending(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/campaigns/${id}/send`, { method: "POST" })
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.sent > 0) fetchCampaign()
|
||||||
|
} catch (e) { console.error("Failed to send campaign", e); toast.error("Failed to send campaign") }
|
||||||
|
setSending(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStatus = async (status: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/campaigns/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status }),
|
||||||
|
})
|
||||||
|
if (!res.ok) console.error("Failed to update campaign status:", res.status, res.statusText)
|
||||||
|
fetchCampaign()
|
||||||
|
} catch (err) { console.error("Failed to update campaign status:", err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>
|
||||||
|
if (!campaign) return <div className="p-8 text-center text-muted-foreground">Campaign not found</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={campaign.name}
|
||||||
|
description={campaign.description || "Email campaign"}
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||||
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<Card className="p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Status</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline">{campaign.status}</Badge>
|
||||||
|
{campaign.status === "draft" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Activate</Button>}
|
||||||
|
{campaign.status === "active" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("paused")}><Pause className="h-3 w-3 mr-1" />Pause</Button>}
|
||||||
|
{campaign.status === "paused" && <Button size="sm" variant="outline" className="h-6 text-xs" onClick={() => updateStatus("active")}><Play className="h-3 w-3 mr-1" />Resume</Button>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Subscribers</p>
|
||||||
|
<p className="text-2xl font-bold flex items-center gap-2"><Users className="h-5 w-5 text-muted-foreground" />{campaign.subscribers?.length || 0}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Steps</p>
|
||||||
|
<p className="text-2xl font-bold flex items-center gap-2"><Layers className="h-5 w-5 text-muted-foreground" />{campaign.steps?.length || 0}</p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 space-y-1">
|
||||||
|
<p className="text-xs text-muted-foreground">Created</p>
|
||||||
|
<p className="text-sm font-medium">{new Date(campaign.created_at).toLocaleDateString()}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="steps">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="steps">Steps ({campaign.steps?.length || 0})</TabsTrigger>
|
||||||
|
<TabsTrigger value="subscribers">Subscribers ({campaign.subscribers?.length || 0})</TabsTrigger>
|
||||||
|
<TabsTrigger value="send">Send</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="steps" className="space-y-4">
|
||||||
|
<Card className="p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium">Add Step</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Subject *</label>
|
||||||
|
<Input value={newStep.subject} onChange={e => setNewStep(p => ({ ...p, subject: e.target.value }))} placeholder="Follow-up: {{name}}" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Delay (days)</label>
|
||||||
|
<Input type="number" min="0" value={newStep.delayDays} onChange={e => setNewStep(p => ({ ...p, delayDays: parseInt(e.target.value) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button size="sm" onClick={addStep} disabled={addingStep || !newStep.subject.trim()}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" /> Add Step
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-muted-foreground">Email Body (use {{name}} for recipient name)</label>
|
||||||
|
<textarea value={newStep.bodyText} onChange={e => setNewStep(p => ({ ...p, bodyText: e.target.value }))} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="Hi {{name}}, We'd love to help with..." />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{campaign.steps?.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center text-muted-foreground">
|
||||||
|
<p>No steps defined yet. Add your first email above.</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{campaign.steps.map((step: any, i: number) => (
|
||||||
|
<Card key={step.id} className="p-4 flex items-start gap-4">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-bold text-primary">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{step.subject}</p>
|
||||||
|
{step.body_text && <p className="text-xs text-muted-foreground mt-1 line-clamp-2">{step.body_text}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground shrink-0">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{step.delay_days > 0 ? `Wait ${step.delay_days}d` : "Immediate"}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="subscribers" className="space-y-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<p className="text-sm font-medium mb-2">Add Leads to Campaign</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input placeholder="Search leads by name or company..." value={leadSearch} onChange={e => searchLeads(e.target.value)} className="flex-1" />
|
||||||
|
</div>
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="mt-2 border rounded-lg divide-y max-h-48 overflow-y-auto">
|
||||||
|
{searchResults.map((l: any) => (
|
||||||
|
<div key={l.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm">{l.companyName || l.contactName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{l.email}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-xs"
|
||||||
|
disabled={subscribing || campaign.subscribers?.some((s: any) => s.lead_id === l.id)}
|
||||||
|
onClick={() => subscribeLeads([l.id])}
|
||||||
|
>
|
||||||
|
{campaign.subscribers?.some((s: any) => s.lead_id === l.id) ? "Added" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{campaign.subscribers?.length === 0 ? (
|
||||||
|
<Card className="p-8 text-center text-muted-foreground">
|
||||||
|
<p>No subscribers yet. Search and add leads above.</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y rounded-lg border">
|
||||||
|
{campaign.subscribers.map((s: any) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{s.contact_name || s.lead_email || s.email}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{s.email} · Step {s.current_step + 1}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className="text-[10px]">{s.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="send" className="space-y-4">
|
||||||
|
<Card className="p-6 text-center space-y-4">
|
||||||
|
<Mail className="h-10 w-10 text-muted-foreground mx-auto" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Send Next Emails</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{campaign.subscribers?.filter((s: any) => s.status === "pending").length || 0} subscribers ready for the next step
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={sendCampaign} disabled={sending || campaign.steps?.length === 0}>
|
||||||
|
<Send className="mr-1.5 h-4 w-4" />
|
||||||
|
{sending ? "Sending..." : "Send Next Batch"}
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Layers({ className }: { className?: string }) {
|
||||||
|
return <svg className={className} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.57 3.91a2 2 0 0 0 1.66 0l8.57-3.91a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></svg>
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
export default function NewCampaignPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [name, setName] = useState("")
|
||||||
|
const [description, setDescription] = useState("")
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim()) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/campaigns", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name: name.trim(), description: description.trim() || null }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.id) router.push(`/campaigns/${data.id}`)
|
||||||
|
} catch (e) { console.error("Campaign create error:", e); toast.error("Failed to create campaign") }
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl mx-auto">
|
||||||
|
<PageHeader title="New Campaign" description="Create an automated email sequence">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.push("/campaigns")}>
|
||||||
|
<ArrowLeft className="mr-1.5 h-4 w-4" /> Back
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
<Card className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Campaign Name *</label>
|
||||||
|
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. New Lead Follow-up" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm font-medium">Description</label>
|
||||||
|
<textarea value={description} onChange={e => setDescription(e.target.value)} className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm" placeholder="What is this campaign for?" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline" onClick={() => router.push("/campaigns")}>Cancel</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={saving || !name.trim()}>{saving ? "Creating..." : "Create Campaign"}</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Plus, Mail, Users, Layers, Calendar } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
draft: "bg-zinc-100 text-zinc-700 border-zinc-300",
|
||||||
|
active: "bg-emerald-100 text-emerald-700 border-emerald-300",
|
||||||
|
paused: "bg-amber-100 text-amber-700 border-amber-300",
|
||||||
|
completed: "bg-blue-100 text-blue-700 border-blue-300",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [campaigns, setCampaigns] = useState<any[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/campaigns")
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => { if (Array.isArray(d)) setCampaigns(d); else setCampaigns([]) })
|
||||||
|
.catch((e) => { console.error("Failed to load campaigns", e); toast.error("Failed to load campaigns"); setCampaigns([]) })
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="Email Campaigns" description="Automated drip sequences for lead follow-ups">
|
||||||
|
<Button size="sm" onClick={() => router.push("/campaigns/new")}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" /> New Campaign
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[1,2,3].map(i => <Card key={i} className="h-32 animate-pulse p-4"><div className="h-4 w-32 rounded bg-muted" /></Card>)}
|
||||||
|
</div>
|
||||||
|
) : campaigns.length === 0 ? (
|
||||||
|
<Card className="p-12 text-center">
|
||||||
|
<Mail className="h-10 w-10 text-muted-foreground mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">No campaigns yet</p>
|
||||||
|
<Button variant="outline" size="sm" className="mt-4" onClick={() => router.push("/campaigns/new")}>
|
||||||
|
Create your first campaign
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{campaigns.map((c) => (
|
||||||
|
<Card key={c.id} className="p-4 cursor-pointer hover:border-primary/50 transition-colors" onClick={() => router.push(`/campaigns/${c.id}`)}>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium truncate">{c.name}</p>
|
||||||
|
{c.description && <p className="text-xs text-muted-foreground truncate mt-0.5">{c.description}</p>}
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" className={`text-[10px] ml-2 ${statusColors[c.status] || ""}`}>{c.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.subscriber_count || 0}</span>
|
||||||
|
<span className="flex items-center gap-1"><Layers className="h-3 w-3" />{c.step_count || 0} steps</span>
|
||||||
|
<span className="flex items-center gap-1"><Calendar className="h-3 w-3" />{new Date(c.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,9 +15,10 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
Search, Send, Phone, Video, MoreHorizontal, Paperclip,
|
Search, Send, Phone, MoreHorizontal, Paperclip,
|
||||||
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
|
||||||
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
|
||||||
|
Pin, PinOff,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
import { hasBlockedCodeExtension, filterBlockedFiles } from "@/lib/blocked-extensions"
|
||||||
import {
|
import {
|
||||||
@@ -211,8 +212,15 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
const messages = useMemo(() => conversationMessages.get(activeChat || "") || [], [conversationMessages, activeChat])
|
||||||
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
const conversation = useMemo(() => conversations.find((c) => c.id === activeChat), [conversations, activeChat])
|
||||||
const filteredConversations = useMemo(() => {
|
const filteredConversations = useMemo(() => {
|
||||||
if (!searchQuery.trim()) return conversations
|
let list = conversations
|
||||||
return conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
if (searchQuery.trim()) {
|
||||||
|
list = conversations.filter((conv) => otherParticipant(conv).name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
}
|
||||||
|
return [...list].sort((a, b) => {
|
||||||
|
if (a.pinned && !b.pinned) return -1
|
||||||
|
if (!a.pinned && b.pinned) return 1
|
||||||
|
return 0
|
||||||
|
})
|
||||||
}, [conversations, searchQuery])
|
}, [conversations, searchQuery])
|
||||||
|
|
||||||
// Fetch conversations from API
|
// Fetch conversations from API
|
||||||
@@ -227,8 +235,8 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
setUnreadMap(new Map(convs.map((c: any) => [c.id, c.unread])))
|
||||||
if (convs.length > 0) setActiveChat(convs[0].id)
|
if (convs.length > 0) setActiveChat(convs[0].id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch conversations in chats page")
|
console.error("Failed to fetch conversations:", err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingChats(false)
|
setLoadingChats(false)
|
||||||
}
|
}
|
||||||
@@ -289,11 +297,11 @@ const formatPreviewContent = (content: string) => {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// ignore
|
console.error("Failed to fetch messages:", err)
|
||||||
}
|
}
|
||||||
// Mark conversation as read
|
// Mark conversation as read
|
||||||
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch(() => {})
|
fetch(`/api/conversations/${activeChat}/read`, { method: "POST" }).catch((err) => console.error("Failed to mark read:", err))
|
||||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||||
}
|
}
|
||||||
fetchMsgs()
|
fetchMsgs()
|
||||||
@@ -312,7 +320,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
const existingIds = new Set(conversations.map((c) => otherParticipant(c).id))
|
||||||
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
setSearchResults(data.users.filter((u: any) => !existingIds.has(u.id)))
|
||||||
}
|
}
|
||||||
} catch { console.warn("Failed to search users in chats page") }
|
} catch (err) { console.error("Failed to search users:", err) }
|
||||||
setSearchingUsers(false)
|
setSearchingUsers(false)
|
||||||
}, 300)
|
}, 300)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
@@ -502,11 +510,14 @@ const formatPreviewContent = (content: string) => {
|
|||||||
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
? { ...conv, lastMessage: getDisplayContent(content), lastMessageTime: "Just now" }
|
||||||
: conv
|
: conv
|
||||||
)
|
)
|
||||||
|
const conv = updated.find((c) => c.id === activeChat)
|
||||||
|
if (conv && !conv.pinned) {
|
||||||
const idx = updated.findIndex((c) => c.id === activeChat)
|
const idx = updated.findIndex((c) => c.id === activeChat)
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
const [item] = updated.splice(idx, 1)
|
const [item] = updated.splice(idx, 1)
|
||||||
updated.unshift(item)
|
updated.unshift(item)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(activeChat, 0); return m })
|
||||||
@@ -776,6 +787,23 @@ const formatPreviewContent = (content: string) => {
|
|||||||
|
|
||||||
const isImageFile = (file: File) => file.type.startsWith("image/")
|
const isImageFile = (file: File) => file.type.startsWith("image/")
|
||||||
|
|
||||||
|
const handleTogglePin = async (convId: string, pinned: boolean) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/conversations/${convId}/pin`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pin: !pinned }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) => (c.id === convId ? { ...c, pinned: !pinned } : c))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to toggle pin:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getDisplayContent = (content: string) => {
|
const getDisplayContent = (content: string) => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content)
|
const parsed = JSON.parse(content)
|
||||||
@@ -792,7 +820,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
<div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
|
||||||
{/* Conversations list - left panel */}
|
{/* Conversations list - left panel */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-r shrink-0 overflow-hidden"
|
className="flex flex-col border-r shrink-0 overflow-hidden"
|
||||||
@@ -817,7 +845,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
setUnreadMap((prev) => { const m = new Map(prev); m.set(conv.id, 0); return m })
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const idx = prev.findIndex((c) => c.id === conv.id)
|
const idx = prev.findIndex((c) => c.id === conv.id)
|
||||||
if (idx > 0) {
|
if (idx > 0 && !conv.pinned) {
|
||||||
const u = [...prev]
|
const u = [...prev]
|
||||||
const [item] = u.splice(idx, 1)
|
const [item] = u.splice(idx, 1)
|
||||||
u.unshift(item)
|
u.unshift(item)
|
||||||
@@ -827,7 +855,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
"w-full group flex items-start gap-3 p-4 text-left transition-colors hover:bg-muted/50 relative",
|
||||||
isActive && "bg-muted"
|
isActive && "bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -837,8 +865,21 @@ const formatPreviewContent = (content: string) => {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
{conv.pinned && <Pin className="h-3 w-3 shrink-0 text-muted-foreground rotate-45" />}
|
||||||
<span className="text-sm font-medium truncate">{person.name}</span>
|
<span className="text-sm font-medium truncate">{person.name}</span>
|
||||||
<span className="text-xs text-muted-foreground shrink-0">{conv.lastMessageTime}</span>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleTogglePin(conv.id, conv.pinned) }}
|
||||||
|
className="h-5 w-5 flex items-center justify-center rounded hover:bg-muted-foreground/20 transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
title={conv.pinned ? "Unpin" : "Pin"}
|
||||||
|
>
|
||||||
|
{conv.pinned ? <PinOff className="h-3 w-3 text-muted-foreground" /> : <Pin className="h-3 w-3 text-muted-foreground rotate-45" />}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-muted-foreground">{conv.lastMessageTime}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
<p className="text-xs text-muted-foreground truncate mt-0.5">{formatPreviewContent(conv.lastMessage)}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -871,7 +912,7 @@ const formatPreviewContent = (content: string) => {
|
|||||||
setSearchQuery("")
|
setSearchQuery("")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { console.warn("Failed to create conversation in chats page") }
|
} catch (err) { console.error("Failed to create conversation:", err) }
|
||||||
}}
|
}}
|
||||||
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
className="w-full flex items-center gap-3 p-4 text-left transition-colors hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
@@ -923,9 +964,6 @@ const formatPreviewContent = (content: string) => {
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
|
||||||
<Phone className="h-4 w-4" />
|
<Phone className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
|
|
||||||
<Video className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
|
||||||
<CalendarDays className="h-4 w-4" />
|
<CalendarDays className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { DashboardStats } from "@/types"
|
import { DashboardStats } from "@/types"
|
||||||
import { CrossedLightsabers } from "@/components/dashboard/crossed-lightsabers"
|
|
||||||
import { StarField } from "@/components/dashboard/star-field"
|
|
||||||
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
import { useWebsiteTheme } from "@/providers/website-theme-provider"
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { websiteTheme: theme } = useWebsiteTheme()
|
const { websiteTheme } = useWebsiteTheme()
|
||||||
const [period, setPeriod] = useState("6months")
|
const [period, setPeriod] = useState("6months")
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||||
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
const pollingRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
@@ -41,8 +39,8 @@ export default function DashboardPage() {
|
|||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setStats(data)
|
setStats(data)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch dashboard stats")
|
console.error("Failed to fetch dashboard stats:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,22 +65,12 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 relative">
|
<div className="space-y-6 relative">
|
||||||
{theme === "starforce" && <StarField />}
|
|
||||||
<div className="relative z-[1] space-y-6">
|
<div className="relative z-[1] space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={<span style={{fontFamily:"'Audiowide',sans-serif"}}>Dashboard</span>}
|
title={<span style={{fontFamily:"'Bangers',cursive",letterSpacing:"0.05em"}}>Dashboard</span>}
|
||||||
description="Overview of your sales pipeline"
|
description="Overview of your sales pipeline"
|
||||||
>
|
>
|
||||||
{theme === "starforce" && (
|
|
||||||
<span
|
|
||||||
className="pointer-events-none select-none text-5xl text-gray-600 dark:text-[#b0f0c0] tracking-wider font-bold text-glow whitespace-nowrap"
|
|
||||||
style={{ fontFamily: "'Cormorant Garamond', serif" }}
|
|
||||||
>
|
|
||||||
Trust the Force, Find your Path
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{theme === "starforce" && <CrossedLightsabers />}
|
|
||||||
<Select value={period} onValueChange={setPeriod}>
|
<Select value={period} onValueChange={setPeriod}>
|
||||||
<SelectTrigger className="h-9 w-[160px]">
|
<SelectTrigger className="h-9 w-[160px]">
|
||||||
<ListFilter className="mr-2 h-4 w-4" />
|
<ListFilter className="mr-2 h-4 w-4" />
|
||||||
@@ -98,7 +86,7 @@ export default function DashboardPage() {
|
|||||||
</PageHeader>
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
{stats
|
{stats
|
||||||
@@ -109,7 +97,7 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
|
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
<LeadStatusChart data={stats?.statusDistribution ?? []} />
|
||||||
@@ -118,12 +106,13 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
|
||||||
</div>
|
</div>
|
||||||
{/* Daily Bugle watermark */}
|
{websiteTheme === "spidey" && (
|
||||||
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.04] dark:opacity-[0.06]">
|
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
|
||||||
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
|
||||||
DAILY BUGLE
|
DAILY BUGLE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function EmailsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) { router.push("/login"); return }
|
if (!user) { router.push("/login"); return }
|
||||||
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
|
if (user.role !== "admin" && user.role !== "super_admin") { router.push("/dashboard"); return }
|
||||||
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || []))
|
fetch("/api/emails").then((r) => r.ok ? r.json() : { emails: [] }).then((d) => setEmails(d.emails || [])).catch((err) => console.error("Failed to fetch emails:", err))
|
||||||
}, [user, router])
|
}, [user, router])
|
||||||
|
|
||||||
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
|
if (!user || (user.role !== "admin" && user.role !== "super_admin")) return null
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import { AppShell } from "@/components/layout/app-shell"
|
import { AppShell } from "@/components/layout/app-shell"
|
||||||
import { UserProvider, useUser } from "@/providers/user-provider"
|
import { UserProvider, useUser } from "@/providers/user-provider"
|
||||||
import { NotificationProvider } from "@/providers/notification-provider"
|
import { NotificationProvider } from "@/providers/notification-provider"
|
||||||
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
|
|||||||
|
|
||||||
function DashboardContent({ children }: { children: React.ReactNode }) {
|
function DashboardContent({ children }: { children: React.ReactNode }) {
|
||||||
const { user, loading } = useUser()
|
const { user, loading } = useUser()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
if (loading) {
|
useEffect(() => {
|
||||||
|
if (!user && !loading) {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}, [user, loading, router])
|
||||||
|
|
||||||
|
if (loading || !user) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) return null
|
|
||||||
|
|
||||||
return <AppShell>{children}</AppShell>
|
return <AppShell>{children}</AppShell>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ export default function LeadDetailsPage({ params }: { params: Promise<{ id: stri
|
|||||||
fetchNotes(),
|
fetchNotes(),
|
||||||
])
|
])
|
||||||
if (leadRes.ok) setLead(await leadRes.json())
|
if (leadRes.ok) setLead(await leadRes.json())
|
||||||
} catch {
|
} catch (err) {
|
||||||
console.warn("Failed to fetch lead details")
|
console.error("Failed to fetch lead details:", err)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PageHeader } from "@/components/shared/page-header"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { GripVertical, Plus, User, Building2, Mail, Phone, Hash } from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
interface KanbanLead {
|
||||||
|
id: string
|
||||||
|
company_name: string
|
||||||
|
contact_name: string
|
||||||
|
email: string
|
||||||
|
phone: string
|
||||||
|
score: number
|
||||||
|
notes: string
|
||||||
|
assigned_to: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KanbanColumn {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
sortOrder: number
|
||||||
|
probability: number
|
||||||
|
leads: KanbanLead[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, { header: string; border: string; dot: string }> = {
|
||||||
|
open: { header: "bg-blue-500/10 border-blue-500/20", border: "border-blue-500/20", dot: "bg-blue-500" },
|
||||||
|
contacted: { header: "bg-amber-500/10 border-amber-500/20", border: "border-amber-500/20", dot: "bg-amber-500" },
|
||||||
|
pending: { header: "bg-purple-500/10 border-purple-500/20", border: "border-purple-500/20", dot: "bg-purple-500" },
|
||||||
|
closed: { header: "bg-emerald-500/10 border-emerald-500/20", border: "border-emerald-500/20", dot: "bg-emerald-500" },
|
||||||
|
ignored: { header: "bg-zinc-500/10 border-zinc-500/20", border: "border-zinc-500/20", dot: "bg-zinc-500" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnGradient: Record<string, string> = {
|
||||||
|
open: "from-blue-500/5",
|
||||||
|
contacted: "from-amber-500/5",
|
||||||
|
pending: "from-purple-500/5",
|
||||||
|
closed: "from-emerald-500/5",
|
||||||
|
ignored: "from-zinc-500/5",
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const [columns, setColumns] = useState<KanbanColumn[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [draggedLead, setDraggedLead] = useState<{ leadId: string; fromStatus: string } | null>(null)
|
||||||
|
|
||||||
|
const fetchKanban = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/leads/kanban")
|
||||||
|
const data = await res.json()
|
||||||
|
setColumns(data)
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to load kanban data")
|
||||||
|
console.error("Failed to load kanban", e)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { fetchKanban() }, [fetchKanban])
|
||||||
|
|
||||||
|
const handleDragStart = (leadId: string, status: string) => {
|
||||||
|
setDraggedLead({ leadId, fromStatus: status })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (toStatus: string) => {
|
||||||
|
if (!draggedLead || draggedLead.fromStatus === toStatus) {
|
||||||
|
setDraggedLead(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setColumns((prev) => {
|
||||||
|
const next = prev.map((col) => ({ ...col, leads: [...col.leads] }))
|
||||||
|
const fromCol = next.find((c) => c.status === draggedLead.fromStatus)
|
||||||
|
const toCol = next.find((c) => c.status === toStatus)
|
||||||
|
if (!fromCol || !toCol) return prev
|
||||||
|
const leadIdx = fromCol.leads.findIndex((l) => l.id === draggedLead.leadId)
|
||||||
|
if (leadIdx === -1) return prev
|
||||||
|
const [moved] = fromCol.leads.splice(leadIdx, 1)
|
||||||
|
toCol.leads.unshift(moved)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(`/api/leads/${draggedLead.leadId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ status: toStatus }),
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("Failed to update lead status")
|
||||||
|
console.error("Failed to update lead status", e)
|
||||||
|
fetchKanban()
|
||||||
|
}
|
||||||
|
setDraggedLead(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader title="Kanban Board" description="Drag and drop leads to update their stage" />
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className="flex h-96 w-72 shrink-0 animate-pulse rounded-lg bg-muted" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<PageHeader
|
||||||
|
title="Kanban Board"
|
||||||
|
description="Drag and drop leads between stages to update their status"
|
||||||
|
>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => router.push("/leads")}>
|
||||||
|
Table View
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => router.push("/leads/new")}>
|
||||||
|
<Plus className="mr-1.5 h-4 w-4" /> New Lead
|
||||||
|
</Button>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4" style={{ minHeight: "calc(100vh - 12rem)" }}>
|
||||||
|
{columns.map((col) => {
|
||||||
|
const colors = statusColors[col.status] || statusColors.open
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className="flex w-72 shrink-0 flex-col rounded-lg border bg-card"
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={() => handleDrop(col.status)}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center justify-between rounded-t-lg border-b px-3 py-2.5 ${colors.header}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`h-2 w-2 rounded-full ${colors.dot}`} />
|
||||||
|
<span className="text-sm font-semibold">{col.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">({col.leads.length})</span>
|
||||||
|
</div>
|
||||||
|
{col.probability > 0 && (
|
||||||
|
<span className="text-xs text-muted-foreground">{col.probability}%</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-2 overflow-y-auto p-2">
|
||||||
|
{col.leads.length === 0 && (
|
||||||
|
<div className="flex h-20 items-center justify-center">
|
||||||
|
<p className="text-xs text-muted-foreground">No leads</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{col.leads.map((lead) => (
|
||||||
|
<LeadCard
|
||||||
|
key={lead.id}
|
||||||
|
lead={lead}
|
||||||
|
status={col.status}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onClick={() => router.push(`/leads/${lead.id}`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeadCard({
|
||||||
|
lead,
|
||||||
|
status,
|
||||||
|
onDragStart,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
lead: KanbanLead
|
||||||
|
status: string
|
||||||
|
onDragStart: (id: string, status: string) => void
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
const initials = lead.contact_name
|
||||||
|
?.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
draggable
|
||||||
|
onDragStart={() => onDragStart(lead.id, status)}
|
||||||
|
onClick={onClick}
|
||||||
|
className="cursor-grab active:cursor-grabbing p-3 space-y-2 transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Avatar className="h-7 w-7 shrink-0">
|
||||||
|
<AvatarFallback className="text-[10px]">{initials}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">{lead.contact_name}</p>
|
||||||
|
{lead.company_name && (
|
||||||
|
<p className="flex items-center gap-1 text-xs text-muted-foreground truncate">
|
||||||
|
<Building2 className="h-3 w-3 shrink-0" />
|
||||||
|
{lead.company_name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 text-muted-foreground/40" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lead.email && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||||
|
<Mail className="h-3 w-3 shrink-0" />
|
||||||
|
{lead.email}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lead.phone && (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs text-muted-foreground truncate">
|
||||||
|
<Phone className="h-3 w-3 shrink-0" />
|
||||||
|
{lead.phone}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lead.score > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Hash className="h-3 w-3 text-muted-foreground/60" />
|
||||||
|
<div className="flex h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${lead.score}%`,
|
||||||
|
backgroundColor:
|
||||||
|
lead.score >= 70 ? "#22c55e" : lead.score >= 40 ? "#eab308" : "#ef4444",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{lead.score}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user