Compare commits

...

21 Commits

Author SHA1 Message Date
JCBSComputer f928ef0fd8 Resolve merge conflicts: keep both spidey/cosmic decorations and theme settings
Build & Auto-Repair / build (push) Has been cancelled
2026-06-29 16:42:49 +02:00
JCBSComputer 1d080761fb Themes/ Ram Increased to 16GB 2026-06-29 16:38:40 +02:00
caitlin 7a722a7d7e Merge remote-tracking branch 'origin/main' into master 2026-06-29 15:38:18 +02:00
caitlin 61e9fac352 Fixed Light Mode 2026-06-29 15:26:44 +02:00
Ace 878627a6ba Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 15:08:09 +02:00
Ace 2c3a8e5333 Added qwen to serve as an repair agent. 2026-06-29 15:06:03 +02:00
Hannah_Bagga d870fb3ac7 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 14:52:13 +02:00
Hannah_Bagga f6d1d91fc6 AI Chat page looks better. Caitlin is my witness 2026-06-29 14:51:59 +02:00
TroodonEnjoyer bc3e345a27 Fix login session persistence + add admin accounts
- Fix JWT secret regenerating on every HMR cycle (globalThis cache)
- Replace unreliable response.cookies.set() with manual Set-Cookie header
- Remove jwt-secret.ts abstraction — JWT_SECRET back inline in auth.ts
- Sidebar logo links directly to /dashboard instead of server-redirect /
- Add 3 admin users to seed: ewan/caitlin/dillen (ADMIN role)
- Fix bcrypt hash corruption from PowerShell $ interpolation
- Remove plaintext passwords from codebase
- Fix audit_password_change trigger UUID type error
2026-06-29 14:42:16 +02:00
JCBSComputer 54b19123ca Merge branch 'main' of https://git.coastit.co.za/CRM_ENVIROMENT/Newbie_CRM 2026-06-29 13:50:54 +02:00
caitlin 3c0e7d76ca Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-29 10:42:58 +02:00
TroodonEnjoyer 39fb39db12 Fixed the login again? 2026-06-26 22:30:54 +02:00
Ace cd37d5a987 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 21:36:17 +02:00
Ace d138c60203 Fixing calander 2026-06-26 21:36:09 +02:00
caitlin 4d7a59c278 Changes in lightmode and dark mode header 2026-06-26 21:31:20 +02:00
TroodonEnjoyer aac9817ee7 Fix double login: set cookie on response object, invalidate sessions on dev restart
- Move cookie setting from cookies().set() to response.cookies.set() in
  login/logout routes (Next.js 15 ambient cookie merge is unreliable)
- Generate random JWT_SECRET at module scope in auth.ts for dev mode so
  every server start invalidates all prior sessions
- Replace blank page on auth failure with redirect to /login in dashboard layout
- Preserve ?redirect= param from middleware in login form
- Wrap login page in Suspense boundary for useSearchParams()

Fixes: double-login bug, session persistence across restarts, blank page on
session expiry
2026-06-26 21:29:36 +02:00
Hannah_Bagga fa97abb5b6 Merge branch 'main' of https://git.coastit.co.za/caitlin/CRM_ENVR 2026-06-26 20:59:21 +02:00
Hannah_Bagga 3998285fd5 Fixed the gif picker just need to add in gifs 2026-06-26 20:58:57 +02:00
Hannah_Bagga 4c1db42873 Chat page fits whole screen now 2026-06-26 20:46:20 +02:00
Hannah_Bagga c428435f2f Removed video call, fixed logo, CRM changes with color theme 2026-06-26 20:30:46 +02:00
TroodonEnjoyer 1b5f244f28 Login possible fix and something else I can't remember 2026-06-26 19:39:45 +02:00
84 changed files with 4124 additions and 845 deletions
+58
View File
@@ -0,0 +1,58 @@
name: Build & Auto-Repair
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Playwright
run: npx playwright install chromium firefox
- name: Build check
id: build
continue-on-error: true
run: npx tsc --noEmit
- name: Run self-healing setup
if: steps.build.outcome == 'failure'
run: node scripts/setup.mjs --self-heal
- name: Run code repair agent
if: steps.build.outcome == 'failure'
env:
OLLAMA_HOST: ${{ vars.OLLAMA_HOST || 'http://localhost:11434' }}
run: node scripts/code-repair-agent.mjs --ci
- name: Re-check build after repair
id: rebuild
continue-on-error: true
run: npx tsc --noEmit
- name: Commit fixes
if: steps.build.outcome == 'failure' && steps.rebuild.outcome == 'success'
run: |
git config user.name "CRM Repair Bot"
git config user.email "bot@coastit.co.za"
git add -A
git diff --cached --quiet || git commit -m "[bot] Auto-repair build errors"
git push
- name: Build failed — report
if: steps.rebuild.outcome == 'failure'
run: |
echo "::error::Build still failing after auto-repair. Manual intervention required."
exit 1
+42
View File
@@ -0,0 +1,42 @@
{
"version": 2,
"description": "Template registry for self-healing setup. Maps internal import paths to templates.",
"templates": {
"data/stickers": {
"template": "stickers.ts",
"description": "Emoji-based sticker packs for media picker"
},
"data/constants": {
"template": "constants.ts",
"description": "App-wide constants (lead statuses, roles, event types)"
},
"lib/utils": {
"template": "utils.ts",
"description": "Utility functions (cn, formatters, helpers)"
},
"types/index": {
"template": "types-index.ts",
"description": "Core type definitions (User, Lead, DashboardStats)"
},
"hooks/use-media": {
"template": "hooks/use-media.ts",
"description": "Media query hook"
},
"hooks/use-debounce": {
"template": "hooks/use-debounce.ts",
"description": "Debounce hook"
},
"hooks/use-local-storage": {
"template": "hooks/use-local-storage.ts",
"description": "LocalStorage hook with SSR safety"
}
},
"fallbackTemplates": {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts"
}
}
@@ -0,0 +1,7 @@
// ── Generic Component Template ────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/components/* imports. Customize as needed.
export default function GenericComponent() {
return <div />;
}
+54
View File
@@ -0,0 +1,54 @@
// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/constants.ts to customize.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
export const EVENT_STATUSES = [
"scheduled",
"completed",
"cancelled",
"rescheduled",
] as const;
export const AI_MODELS = {
chat: "llama3.2:3b",
scraper: "dolphin-llama3:8b",
coder: "qwen2.5-coder:1.5b-base",
} as const;
export const PAGINATION = {
defaultLimit: 50,
maxLimit: 200,
} as const;
export const DATE_FORMATS = {
short: "MMM d, yyyy",
long: "MMMM d, yyyy",
time: "h:mm a",
datetime: "MMM d, yyyy h:mm a",
} as const;
@@ -0,0 +1,17 @@
// ── Generic Data Module Template ─────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/data/* imports. Customize as needed.
export interface DataItem {
id: string;
name: string;
[key: string]: unknown;
}
export function getDataItems(): DataItem[] {
return [];
}
export function getDataItem(id: string): DataItem | undefined {
return undefined;
}
@@ -0,0 +1,10 @@
// ── Generic Hook Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/hooks/* imports. Customize as needed.
import { useState, useEffect } from "react";
export function useHook<T>(initialValue: T): [T, (value: T) => void] {
const [value, setValue] = useState(initialValue);
return [value, setValue];
}
@@ -0,0 +1,15 @@
// ── useDebounce Hook ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
@@ -0,0 +1,29 @@
// ── useLocalStorage Hook ──────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(initialValue);
useEffect(() => {
try {
const item = window.localStorage.getItem(key);
if (item) setStoredValue(JSON.parse(item));
} catch {
// corrupted data — use initial value
}
}, [key]);
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch {
// storage full or disabled
}
};
return [storedValue, setValue];
}
@@ -0,0 +1,18 @@
// ── useMedia Hook ─────────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit to customize.
import { useState, useEffect } from "react";
export function useMedia(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, [query]);
return matches;
}
@@ -0,0 +1,7 @@
// ── Generic Lib Module Template ──────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/lib/* imports. Customize as needed.
export function libFunction(): void {
// Placeholder
}
+95
View File
@@ -0,0 +1,95 @@
// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
const reactionStickers: Sticker[] = [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
];
const animalStickers: Sticker[] = [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
];
const foodStickers: Sticker[] = [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🍰" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
];
const activityStickers: Sticker[] = [
{ id: "football", name: "Football", emoji: "⚽" },
{ id: "basketball", name: "Basketball", emoji: "🏀" },
{ id: "tennis", name: "Tennis", emoji: "🎾" },
{ id: "running", name: "Running", emoji: "🏃" },
{ id: "swimming", name: "Swimming", emoji: "🏊" },
{ id: "cycling", name: "Cycling", emoji: "🚴" },
{ id: "gym", name: "Gym", emoji: "🏋️" },
{ id: "yoga", name: "Yoga", emoji: "🧘" },
{ id: "music", name: "Music", emoji: "🎵" },
{ id: "party", name: "Party", emoji: "🎉" },
{ id: "gift", name: "Gift", emoji: "🎁" },
{ id: "trophy", name: "Trophy", emoji: "🏆" },
];
const stickerPacks: StickerPack[] = [
{ id: "reactions", name: "Reactions", stickers: reactionStickers },
{ id: "animals", name: "Animals", stickers: animalStickers },
{ id: "food", name: "Food & Drink", stickers: foodStickers },
{ id: "activities", name: "Activities", stickers: activityStickers },
];
export function getStickerPacks(): StickerPack[] {
return stickerPacks;
}
export function getStickerPack(id: string): StickerPack | undefined {
return stickerPacks.find((p) => p.id === id);
}
export function getSticker(packId: string, stickerId: string): Sticker | undefined {
const pack = getStickerPack(packId);
return pack?.stickers.find((s) => s.id === stickerId);
}
@@ -0,0 +1,10 @@
// ── Generic Types Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/types/* imports. Customize as needed.
export type GenericType = Record<string, unknown>;
export interface GenericInterface {
id: string;
[key: string]: unknown;
}
+141
View File
@@ -0,0 +1,141 @@
// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/types-index.ts to customize.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus =
| "new"
| "contacted"
| "qualified"
| "proposal"
| "won"
| "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
monthlyBreakdown: {
label: string;
total: number;
new: number;
contacted: number;
qualified: number;
proposal: number;
won: number;
lost: number;
}[];
leadsPerMonth: { label: string; leads: number; won: number }[];
recentLeads: Lead[];
statusDistribution: { name: string; value: number; color: string }[];
periodLabel: string;
trends: Record<string, { pct: number; up: boolean }>;
}
export interface Note {
id: string;
leadId: string;
userId: string;
authorName: string;
authorAvatar: string;
authorRole: UserRole;
note: string;
createdAt: string;
updatedAt: string;
}
export type NotificationType =
| "lead_created"
| "lead_status_changed"
| "lead_assigned"
| "chat_message"
| "note_added"
| "event_scheduled";
export interface Notification {
id: string;
type: NotificationType;
title: string;
description: string;
timestamp: string;
read: boolean;
link?: string;
}
export interface ChatMessage {
id: string;
conversationId: string;
senderId: string;
senderName: string;
senderAvatar: string;
content: string;
timestamp: string;
}
export interface Conversation {
id: string;
participants: { id: string; name: string; avatar: string; role: string }[];
lastMessage: string;
lastMessageTime: string;
unread: number;
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "email" | "task" | "note";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
description: string | null;
participantNotes: string | null;
eventType: EventType;
startTime: string;
endTime: string | null;
durationMinutes: number | null;
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
@@ -0,0 +1,7 @@
// ── Generic Utils Template ────────────────────────────────────────────
// Auto-generated by self-healing setup.
// Placeholder for @/utils/* imports. Customize as needed.
export function utilsFunction(): void {
// Placeholder
}
+76
View File
@@ -0,0 +1,76 @@
// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/utils.ts to customize.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
export function throttle<T extends (...args: unknown[]) => unknown>(
fn: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle = false;
return (...args: Parameters<T>) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function classNames(...classes: (string | boolean | undefined | null)[]): string {
return classes.filter(Boolean).join(" ");
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function retry<T>(fn: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
return fn().catch((err) =>
retries > 0 ? sleep(delay).then(() => retry(fn, retries - 1, delay * 2)) : Promise.reject(err)
);
}
+282
View File
@@ -0,0 +1,282 @@
.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;
}
+117
View File
@@ -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)"
}
}
+313
View File
@@ -0,0 +1,313 @@
.dark.theme-cyber {
--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-cyber {
--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-cyber body {
background: transparent
linear-gradient(135deg, rgba(0, 10, 20, 0.95), rgba(10, 0, 20, 0.9));
position: relative;
}
.dark.theme-cyber body > div {
background: transparent !important;
}
.dark.theme-cyber 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-cyber 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-cyber 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-cyber .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-cyber .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-cyber h1, .dark.theme-cyber h2, .dark.theme-cyber 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-cyber h1, .light.theme-cyber h2, .light.theme-cyber 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-cyber a {
color: hsl(180, 100%, 55%);
text-decoration: none;
transition: color 0.2s;
}
.light.theme-cyber a {
color: hsl(180, 100%, 35%);
text-decoration: none;
transition: color 0.2s;
}
.dark.theme-cyber a:hover {
color: hsl(280, 80%, 60%);
text-shadow: 0 0 8px rgba(255, 0, 255, 0.4);
}
.light.theme-cyber a:hover {
color: hsl(280, 60%, 50%);
}
.dark.theme-cyber blockquote {
border-left: 3px solid hsl(280, 80%, 55%);
color: hsl(180, 20%, 70%);
font-style: italic;
}
.light.theme-cyber blockquote {
border-left: 3px solid hsl(280, 60%, 50%);
color: hsl(240, 10%, 45%);
font-style: italic;
}
.dark.theme-cyber code {
color: hsl(180, 100%, 55%);
background: rgba(0, 255, 255, 0.08);
border: 1px solid rgba(0, 255, 255, 0.15);
}
.light.theme-cyber code {
color: hsl(280, 60%, 45%);
background: rgba(0, 200, 200, 0.05);
border: 1px solid rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber pre {
background: rgba(0, 5, 15, 0.9);
border: 1px solid rgba(0, 255, 255, 0.1);
}
.light.theme-cyber pre {
background: rgba(0, 200, 200, 0.03);
border: 1px solid rgba(0, 200, 200, 0.08);
}
.dark.theme-cyber hr {
border-color: rgba(0, 255, 255, 0.15);
}
.light.theme-cyber hr {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber table th {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber table th {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber table td {
border-color: rgba(0, 255, 255, 0.12);
}
.light.theme-cyber table td {
border-color: rgba(0, 200, 200, 0.1);
}
.dark.theme-cyber ::selection {
background: rgba(0, 255, 255, 0.2);
color: hsl(0, 0%, 0%);
}
.light.theme-cyber ::selection {
background: rgba(0, 200, 200, 0.12);
color: hsl(0, 0%, 0%);
}
.dark.theme-cyber input::placeholder,
.dark.theme-cyber textarea::placeholder {
color: hsl(180, 20%, 40%);
}
.light.theme-cyber input::placeholder,
.light.theme-cyber textarea::placeholder {
color: hsl(240, 10%, 65%);
}
.dark.theme-cyber .page-header-title {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .page-header-title {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .stat-card-value {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .stat-card-value {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .badge {
color: hsl(180, 100%, 55%);
}
.light.theme-cyber .badge {
color: hsl(180, 100%, 35%);
}
.dark.theme-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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-cyber .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;
}
+69
View File
@@ -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)"]
}
}
}
+35
View File
@@ -0,0 +1,35 @@
/* ============================================================================
Default Theme — Clean, Professional SaaS Design Tokens
This is the standard CRM appearance. No Spidey branding or assets.
============================================================================ */
.theme-default {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 80% 52%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 215 25% 22%;
--input: 215 25% 22%;
--ring: 210 80% 52%;
--radius: 0.5rem;
--sidebar: 222.2 84% 4.9%;
--sidebar-foreground: 210 40% 98%;
--sidebar-primary: 210 80% 52%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217.2 32.6% 17.5%;
--sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 215 25% 22%;
--sidebar-ring: 210 80% 52%;
}
+118 -6
View File
@@ -1,9 +1,7 @@
/* ============================================================================
Spidey Theme — Baseline Design Tokens
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
@import url("./cosmic-theme.css");
@import url("./cyber-theme.css");
.theme-spidey {
.dark.theme-spidey {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
@@ -34,7 +32,38 @@
--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: "";
position: fixed;
inset: 0;
@@ -67,3 +96,86 @@
background-size: 200% 200%;
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;
}
+71 -36
View File
@@ -2,38 +2,73 @@
"theme": {
"id": "spidey",
"name": "Spidey",
"description": "Current website appearance — dark theme with red accents",
"type": "dark",
"description": "Dark mode with red accents and web patterns; light mode with clean red-blue accents",
"type": "dual",
"default": true
},
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
"modes": {
"dark": {
"colors": {
"background": "hsl(222.2, 84%, 4.9%)",
"foreground": "hsl(210, 40%, 98%)",
"card": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)",
"secondaryForeground": "hsl(210, 40%, 98%)",
"muted": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)",
"accent": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)",
"destructive": "hsl(0, 62.8%, 30.6%)",
"destructiveForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)",
"input": "hsl(217.2, 32.6%, 17.5%)",
"ring": "hsl(0, 100%, 53%)",
"sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)"
}
},
"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",
"typography": {
@@ -42,11 +77,11 @@
},
"keyColors": {
"primary": "#FF0000",
"background": "#0a0a0f",
"card": "#141414",
"sidebar": "#0a0a0f",
"border": "#1a1a24",
"text": "#e8e8ef",
"mutedText": "#888888"
"background": "#0a0a0f (dark) / #ffffff (light)",
"card": "#141414 (dark) / #ffffff (light)",
"sidebar": "#0a0a0f (dark) / #ffffff (light)",
"border": "#1a1a24 (dark) / #e2e4ea (light)",
"text": "#e8e8ef (dark) / #0a0a1a (light)",
"mutedText": "#888888 (dark) / #6b7280 (light)"
}
}
+225 -74
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router])
useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -260,7 +291,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
>
<Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div>
</button>
)
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})}
</div>
</div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div>
</div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status}
</span>
</div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
{event.startTime ? (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
<Clock className="h-3 w-3" />
{formatDate(event.startTime)}
<span className="text-muted-foreground/20">·</span>
{formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div>
)}
<div className="flex items-center gap-1 mt-1">
<span className={cn(
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1",
event.userId === user?.id
? "bg-primary/10 text-primary ring-primary/20"
: "bg-muted text-muted-foreground/60 ring-border",
)}>
{event.userId === user?.id
? (event.participant?.name.charAt(0) || "?")
: (event.creator?.name.charAt(0) || "?")}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id
? (event.participant?.name || "No participant")
: (event.creator?.name || "Unknown")}
</span>
{event.developerId === user?.id ? (
<>
<span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
{event.creator?.name.charAt(0) || "?"}
</span>
<span className="text-[10px] text-muted-foreground/60 font-medium">
Assigned by {event.creator?.name || "Unknown"}
</span>
</>
) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium">
{event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
</span>
) : null}
</div>
</div>
</div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div>
</div>
<DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +808,29 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+6 -1
View File
@@ -1,10 +1,11 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other"
export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent {
id: string
userId: string
participantId: string | null
developerId: string | null
leadId: string | null
conversationId: string | null
title: string
@@ -17,6 +18,10 @@ export interface CalendarEvent {
status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string
}
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE)
'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004')
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL;
-- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING;
-- ============================================================================
@@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
-- They will set their own passwords on first login (password_change_required=TRUE).
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
-- after users have set their chosen passwords.
-- ============================================================================
-- FUTURE REQUIREMENT NOTE:
-- If this system is ever exposed publicly, remove reversible password storage
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+9
View File
@@ -66,5 +66,14 @@ BEGIN;
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Migration Complete ==='
COMMIT;
+7 -1
View File
@@ -6,13 +6,19 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:repair": "node scripts/code-repair-agent.mjs --watch",
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs",
"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",
"start": "next start -p 3006",
"lint": "eslint"
+346
View File
@@ -0,0 +1,346 @@
// ── Code Repair Agent ─────────────────────────────────────────────
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
//
// Usage:
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
//
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
// Model: qwen2.5-coder:1.5b-base
//
// ── Architecture ──────────────────────────────────────────────────
// 1. Capture build output from `npx tsc --noEmit`
// 2. Parse error locations (file, line, column, message)
// 3. Read the failing source file + surrounding context (~20 lines)
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
// 5. Apply fix if confidence > threshold (0.7)
// 6. Re-run build to verify fix
// 7. If breaking change detected (wide-reaching import changes), escalate
// 8. In --ci mode: auto-commit with [bot] prefix
import { execSync } from "node:child_process"
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
import { createRequire } from "node:module"
const SELF = dirname(fileURLToPath(import.meta.url))
const _require = createRequire(import.meta.url)
const ROOT = resolve(SELF, "..")
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
const MAX_ITERATIONS = 5
const CONFIDENCE_THRESHOLD = 0.7
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
// ── Helpers ────────────────────────────────────────────────────────
function log(msg, level = "info") {
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
console.log(` ${prefix} [repair] ${msg}`)
}
function runTsc() {
try {
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
return { ok: true, output: out.toString() }
} catch (e) {
return { ok: false, output: e.stdout?.toString() || e.message || "" }
}
}
function parseErrors(output) {
const errors = []
// Match: src/file.ts(line,col): error TSxxxx: message
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
let m
while ((m = lineRegex.exec(output)) !== null) {
errors.push({
file: resolve(ROOT, m[1]),
line: parseInt(m[2], 10),
column: parseInt(m[3], 10),
message: m[4].trim(),
})
}
return errors
}
function readContext(filePath, errorLine, contextLines = 20) {
try {
const lines = readFileSync(filePath, "utf-8").split("\n")
const start = Math.max(0, errorLine - 1 - contextLines)
const end = Math.min(lines.length, errorLine - 1 + contextLines)
return {
content: lines.slice(start, end).join("\n"),
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
totalLines: lines.length,
}
} catch {
return null
}
}
// ── Ollama Integration ─────────────────────────────────────────────
async function queryOllama(prompt) {
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
prompt,
stream: false,
options: { temperature: 0.3, num_predict: 2048 },
}),
})
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
const data = await response.json()
return data.response || ""
}
function buildRepairPrompt(filePath, errorMessage, codeContext) {
return `You are a TypeScript repair agent. Fix the following error.
FILE: ${filePath}
ERROR: ${errorMessage}
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
\`\`\`typescript
${codeContext.content}
\`\`\`
Respond with ONLY the fix JSON in this exact format:
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
Rules:
- Keep the fix minimal and precise
- Only change what's necessary to fix the error
- If the error is a missing import, suggest the import statement
- If the error is a type mismatch, suggest the corrected code
- confidence < 0.7 will not be auto-applied
- If you cannot determine a fix, set confidence to 0`
}
function parseFixResponse(text) {
try {
// Extract JSON from response (it might have markdown fences)
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
if (!jsonMatch) return null
return JSON.parse(jsonMatch[0])
} catch {
return null
}
}
function applyFix(filePath, fixSuggestion) {
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
try {
const original = readFileSync(filePath, "utf-8")
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
return { applied: true, backup: original }
} catch (e) {
return { applied: false, reason: e.message }
}
}
// ── Breaking Change Escalation ─────────────────────────────────────
function isBreakingChange(errors, fixes) {
// Heuristic: if fix touches import structure or exports, it's breaking
for (const fix of fixes) {
if (!fix || !fix.fix) continue
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
return true
}
}
return false
}
async function escalateBreakingChange(errors, fixes) {
const logFile = resolve(ROOT, "repair-escalation.log")
const timestamp = new Date().toISOString()
const entry = [
`=== Breaking Change Escalation @ ${timestamp} ===`,
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
"========================================\n",
].join("\n")
appendFileSync(logFile, entry)
log(`Breaking change escalated — see ${logFile}`, "warn")
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
// Wait for timeout or user intervention
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
log("Escalation timeout reached — applying fixes")
return true
}
// ── Git Auto-Commit ────────────────────────────────────────────────
function gitCommit(message) {
try {
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
log(`Committed: [bot] ${message}`)
return true
} catch (e) {
log(`Git commit failed: ${e.message}`, "warn")
return false
}
}
// ── Main Repair Loop ───────────────────────────────────────────────
async function repairOnce(doCommit = false) {
log("Running build check...")
const build = runTsc()
if (build.ok) {
log("No build errors found")
return { fixed: false, errors: [] }
}
const errors = parseErrors(build.output)
if (errors.length === 0) {
log("Could not parse build errors from output", "warn")
log(build.output.slice(0, 1000))
return { fixed: false, errors: [] }
}
log(`Found ${errors.length} error(s):`)
for (const e of errors.slice(0, 10)) {
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
log(`${shortFile}:${e.line}:${e.column}${e.message}`)
}
const fixes = []
for (const error of errors) {
const context = readContext(error.file, error.line)
if (!context) {
log(`Cannot read ${error.file}`, "error")
continue
}
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
try {
const prompt = buildRepairPrompt(error.file, error.message, context)
const response = await queryOllama(prompt)
const fix = parseFixResponse(response)
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
const result = applyFix(error.file, fix)
if (result.applied) {
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
fixes.push(fix)
} else {
log(`Failed to apply fix: ${result.reason}`, "error")
}
} else {
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
}
} catch (e) {
log(`Ollama error: ${e.message}`, "error")
}
}
if (fixes.length === 0) {
log("No fixes could be applied", "error")
return { fixed: false, errors }
}
// Verify by re-running build
log("Verifying fixes...")
const verify = runTsc()
if (verify.ok) {
log("All fixes verified — build passes!")
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
return { fixed: true, errors: [], fixes }
}
const remainingErrors = parseErrors(verify.output)
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
// Check for breaking changes
if (isBreakingChange(remainingErrors, fixes)) {
log("Breaking change detected — escalating", "warn")
const approved = await escalateBreakingChange(remainingErrors, fixes)
if (approved) {
if (doCommit) gitCommit(`Breaking change applied after escalation`)
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
}
}
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
}
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
for (let i = 0; i < maxIterations; i++) {
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
const result = await repairOnce(doCommit)
if (!result.fixed) {
if (result.errors?.length > 0) {
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
}
break
}
if (result.errors?.length === 0) break
}
log("Repair agent finished")
}
// ── Watch Mode ─────────────────────────────────────────────────────
function watchMode() {
log("Watch mode active — monitoring src/ for changes...", "info")
const chokidar = (() => {
try {
return _require("chokidar")
} catch {
return null
}
})()
if (!chokidar) {
log("chokidar not available — using simple polling (5s interval)", "warn")
setInterval(async () => {
const result = await repairOnce(false)
if (result.fixed) log("Auto-fix applied")
}, 5000)
return
}
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
let debounceTimer = null
watcher.on("change", async (filePath) => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(async () => {
log(`Change detected: ${filePath}`)
await repairLoop(3, false)
}, 1000)
})
log("Watching for changes...")
}
// ── Entry ──────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const isWatch = args.includes("--watch") || args.includes("-w")
const isCI = args.includes("--ci") || args.includes("-c")
async function main() {
if (isWatch) {
watchMode()
} else {
await repairLoop(MAX_ITERATIONS, isCI)
}
}
main().catch((e) => {
console.error("Repair agent crashed:", e)
process.exit(1)
})
+396
View File
@@ -0,0 +1,396 @@
// ── Module Generator ─────────────────────────────────────────────────
// Generates missing internal modules from templates.
// Supports built-in templates and custom .setup-templates/ directory.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..", "..");
const SETUP_TEMPLATES_DIR = path.join(ROOT, ".setup-templates");
const REGISTRY_PATH = path.join(SETUP_TEMPLATES_DIR, "registry.json");
const TEMPLATES_DIR = path.join(SETUP_TEMPLATES_DIR, "templates");
export interface TemplateRegistry {
[importPath: string]: {
template: string;
params?: Record<string, unknown>;
generator?: string; // optional custom generator function name
};
}
export interface GenerationResult {
success: boolean;
filePath: string;
internalPath: string;
message?: string;
error?: string;
}
export interface FallbackRegistry {
[pattern: string]: string;
}
/** Load the template registry from .setup-templates/registry.json */
export function loadRegistry(): { templates: TemplateRegistry; fallbacks: FallbackRegistry } {
const builtInTemplates: TemplateRegistry = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts", params: { inferFromUsage: true } },
};
const builtInFallbacks: FallbackRegistry = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
};
try {
if (fs.existsSync(REGISTRY_PATH)) {
const content = fs.readFileSync(REGISTRY_PATH, "utf-8");
const custom = JSON.parse(content);
return {
templates: { ...builtInTemplates, ...(custom.templates || {}) },
fallbacks: { ...builtInFallbacks, ...(custom.fallbackTemplates || {}) },
};
}
} catch {
// Use built-in
}
return { templates: builtInTemplates, fallbacks: builtInFallbacks };
}
/** Match an internal path against fallback patterns */
export function matchFallback(internalPath: string, fallbacks: FallbackRegistry): string | undefined {
for (const [pattern, template] of Object.entries(fallbacks)) {
// Convert glob pattern to regex
const regexStr = "^" + pattern
.replace(/\//g, "\\/")
.replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&")
+ "$";
const regex = new RegExp(regexStr);
if (regex.test(internalPath)) {
return template;
}
}
return undefined;
}
/** Render a template with parameters */
function renderTemplate(templateContent: string, params: Record<string, unknown> = {}): string {
let result = templateContent;
for (const [key, value] of Object.entries(params)) {
const regex = new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g");
result = result.replace(regex, String(value));
}
return result;
}
/** Get template content - first from custom templates dir, then built-in */
function getTemplateContent(templateName: string): string {
// Check custom templates first
const customPath = path.join(TEMPLATES_DIR, templateName);
if (fs.existsSync(customPath)) {
return fs.readFileSync(customPath, "utf-8");
}
// Built-in templates (embedded)
const builtInTemplates: Record<string, string> = {
"stickers.ts": `// ── Sticker Packs ─────────────────────────────────────────────────
// Auto-generated by self-healing setup. Edit .setup-templates/templates/stickers.ts to customize.
export interface Sticker {
id: string;
name: string;
emoji?: string;
svg?: string;
}
export interface StickerPack {
id: string;
name: string;
stickers: Sticker[];
}
export function getStickerPacks(): StickerPack[] {
return [
{
id: "reactions",
name: "Reactions",
stickers: [
{ id: "thumbs-up", name: "Thumbs Up", emoji: "👍" },
{ id: "thumbs-down", name: "Thumbs Down", emoji: "👎" },
{ id: "heart", name: "Heart", emoji: "❤️" },
{ id: "laugh", name: "Laugh", emoji: "😂" },
{ id: "wow", name: "Wow", emoji: "😮" },
{ id: "sad", name: "Sad", emoji: "😢" },
{ id: "angry", name: "Angry", emoji: "😡" },
{ id: "clap", name: "Clap", emoji: "👏" },
{ id: "fire", name: "Fire", emoji: "🔥" },
{ id: "100", name: "100", emoji: "💯" },
{ id: "eyes", name: "Eyes", emoji: "👀" },
{ id: "pray", name: "Pray", emoji: "🙏" },
],
},
{
id: "animals",
name: "Animals",
stickers: [
{ id: "cat", name: "Cat", emoji: "🐱" },
{ id: "dog", name: "Dog", emoji: "🐶" },
{ id: "panda", name: "Panda", emoji: "🐼" },
{ id: "fox", name: "Fox", emoji: "🦊" },
{ id: "bear", name: "Bear", emoji: "🐻" },
{ id: "koala", name: "Koala", emoji: "🐨" },
{ id: "tiger", name: "Tiger", emoji: "🐯" },
{ id: "lion", name: "Lion", emoji: "🦁" },
{ id: "monkey", name: "Monkey", emoji: "🐵" },
{ id: "frog", name: "Frog", emoji: "🐸" },
{ id: "penguin", name: "Penguin", emoji: "🐧" },
{ id: "bird", name: "Bird", emoji: "🐦" },
],
},
{
id: "food",
name: "Food & Drink",
stickers: [
{ id: "pizza", name: "Pizza", emoji: "🍕" },
{ id: "burger", name: "Burger", emoji: "🍔" },
{ id: "taco", name: "Taco", emoji: "🌮" },
{ id: "sushi", name: "Sushi", emoji: "🍣" },
{ id: "ramen", name: "Ramen", emoji: "🍜" },
{ id: "ice-cream", name: "Ice Cream", emoji: "🍦" },
{ id: "cake", name: "Cake", emoji: "🎂" },
{ id: "coffee", name: "Coffee", emoji: "☕" },
{ id: "beer", name: "Beer", emoji: "🍺" },
{ id: "wine", name: "Wine", emoji: "🍷" },
{ id: "donut", name: "Donut", emoji: "🍩" },
{ id: "cookie", name: "Cookie", emoji: "🍪" },
],
},
];
};
export function getStickerPacks(): StickerPack[] {
return [
// reactions, animals, food packs defined above
// (inline for brevity - full packs in template file)
];
}
`,
"constants.ts": `// ── App Constants ────────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export const APP_NAME = "CoastIT CRM";
export const APP_VERSION = "1.0.0";
export const LEAD_STATUSES = {
new: { label: "New", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-yellow-500" },
qualified: { label: "Qualified", color: "bg-purple-500" },
proposal: { label: "Proposal", color: "bg-orange-500" },
won: { label: "Won", color: "bg-green-500" },
lost: { label: "Lost", color: "bg-red-500" },
} as const;
export const USER_ROLES = {
super_admin: { label: "Super Admin", level: 1 },
admin: { label: "Admin", level: 2 },
sales: { label: "Sales", level: 3 },
dev: { label: "Developer", level: 4 },
} as const;
export const EVENT_TYPES = [
"meeting",
"call",
"email",
"task",
"note",
] as const;
`,
"utils.ts": `// ── Utility Functions ────────────────────────────────────────────────
// Auto-generated by self-healing setup.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: Date | string, options?: Intl.DateTimeFormatOptions): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
...options,
});
}
export function formatTime(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
}
export function formatCurrency(amount: number, currency = "USD"): string {
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str;
return str.slice(0, length - 3) + "...";
}
export function generateId(): string {
return crypto.randomUUID();
}
export function debounce<T extends (...args: unknown[]) => unknown>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
`,
"types-index.ts": `// ── Type Definitions ──────────────────────────────────────────────────
// Auto-generated by self-healing setup.
export type UserRole = "super_admin" | "admin" | "sales" | "dev";
export interface User {
id: string;
name: string;
email: string;
phone?: string;
role: UserRole;
active: boolean;
avatar: string;
createdAt: string;
}
export interface Lead {
id: string;
companyName: string;
contactName: string;
email: string;
phone: string;
source: string;
description: string;
status: LeadStatus;
assignedUserId: string | null;
assignedUser: User | null;
createdAt: string;
updatedAt: string;
}
export type LeadStatus = "new" | "contacted" | "qualified" | "proposal" | "won" | "lost";
export interface DashboardStats {
totalLeads: number;
newLeads: number;
contactedLeads: number;
qualifiedLeads: number;
proposalLeads: number;
wonLeads: number;
lostLeads: number;
conversionRate: number;
}
`,
};
return builtInTemplates[templateName] || "";
}
/** Generate a single missing module */
export function generateModule(
internalPath: string,
templates: TemplateRegistry,
fallbacks: FallbackRegistry
): GenerationResult {
let entry = templates[internalPath];
let templateFile = entry?.template;
// Try fallback patterns
if (!entry) {
const fallbackTemplate = matchFallback(internalPath, fallbacks);
if (fallbackTemplate) {
templateFile = fallbackTemplate;
entry = { template: fallbackTemplate };
}
}
if (!templateFile) {
return {
success: false,
filePath: "",
internalPath,
error: `No template registered for @/${internalPath}`,
};
}
const templateContent = getTemplateContent(templateFile);
if (!templateContent) {
return {
success: false,
filePath: "",
internalPath,
error: `Template not found: ${entry.template}`,
};
}
const rendered = renderTemplate(templateContent, entry.params || {});
const filePath = path.join(ROOT, "src", internalPath + (internalPath.endsWith(".ts") ? "" : ".ts"));
// Ensure directory exists
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
// Write file
try {
fs.writeFileSync(filePath, rendered, "utf-8");
return {
success: true,
filePath,
internalPath,
message: `Generated @/${internalPath}`,
};
} catch (e) {
return {
success: false,
filePath,
internalPath,
error: `Failed to write file: ${e instanceof Error ? e.message : String(e)}`,
};
}
}
/** Generate all missing modules for a list of internal paths */
export function generateAll(internalPaths: string[]): GenerationResult[] {
const { templates, fallbacks } = loadRegistry();
const results: GenerationResult[] = [];
for (const internalPath of internalPaths) {
const result = generateModule(internalPath, templates, fallbacks);
results.push(result);
if (result.success) {
console.log(`${result.message}`);
} else {
console.error(` ✗ Failed @/${internalPath}: ${result.error}`);
}
}
return results;
}
+92
View File
@@ -0,0 +1,92 @@
// ── TypeScript Error Parser ──────────────────────────────────────────
// Parses `tsc --noEmit` or `npm run build` output to extract
// "Module not found: Can't resolve '@/path/to/module'" errors.
// Returns actionable missing module info for the generator.
export interface MissingModule {
/** The import path that failed, e.g. "@/data/stickers" */
importPath: string;
/** The file that contains the failing import */
importerFile: string;
/** Line number in importer file */
line: number;
/** Column number */
column: number;
/** Full error message for context */
message: string;
/** Whether this is an internal (@/...) module we can generate */
isInternal: boolean;
/** The normalized internal path, e.g. "data/stickers" */
internalPath?: string;
}
/** Parse TypeScript compiler output for missing module errors */
export function parseMissingModules(tscOutput: string): MissingModule[] {
const results: MissingModule[] = [];
// Pattern: error TS2307: Cannot find module '@/data/stickers' or its corresponding type declarations.
// File: src/components/chats/media-picker.tsx:10:1
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'\s+or its corresponding type declarations\.\s*(?:\n\s*(.*?):(\d+):(\d+))?/g;
let match;
while ((match = errorRegex.exec(tscOutput)) !== null) {
const importPath = match[1];
const importerFile = match[2] || "";
const line = parseInt(match[3] || "0", 10);
const column = parseInt(match[4] || "0", 10);
// Also handle: Module not found: Error: Can't resolve '@/data/stickers' in 'C:\...\src\components\chats'
const altRegex = /Module not found: Error: Can't resolve\s+'([^']+)'\s+in\s+'([^']+)'/g;
let altMatch;
let altImporter = importerFile;
while ((altMatch = altRegex.exec(tscOutput)) !== null) {
if (altMatch[1] === importPath) {
altImporter = altMatch[2];
break;
}
}
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/");
let internalPath: string | undefined;
if (isInternal) {
internalPath = importPath.replace(/^[@~]\//, "");
}
results.push({
importPath,
importerFile: altImporter,
line,
column,
message: `Cannot find module '${importPath}'`,
isInternal,
internalPath,
});
}
// Deduplicate by importPath + importerFile
const seen = new Set<string>();
return results.filter((m) => {
const key = `${m.importPath}|${m.importerFile}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** Filter to only internal (@/...) modules we can auto-generate */
export function filterGeneratable(modules: MissingModule[]): MissingModule[] {
return modules.filter((m) => m.isInternal && m.internalPath);
}
/** Group missing modules by internal path */
export function groupByInternalPath(modules: MissingModule[]): Map<string, MissingModule[]> {
const groups = new Map<string, MissingModule[]>();
for (const m of modules) {
if (!m.internalPath) continue;
const existing = groups.get(m.internalPath) || [];
existing.push(m);
groups.set(m.internalPath, existing);
}
return groups;
}
+33 -3
View File
@@ -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 ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") {
+195 -16
View File
@@ -5,17 +5,22 @@
// 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists
// 5. --self-heal flag: runs build check + auto-generates missing modules
//
// All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs"
import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
// Auto-detect Python executable (python vs python3)
function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) {
@@ -28,7 +33,6 @@ function detectPython() {
process.exit(1)
}
// Auto-detect pip (pip vs pip3), fall back to python -m pip
function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) {
@@ -40,41 +44,216 @@ function detectPip(python) {
return `${python} -m pip`
}
const PY = detectPython()
const PIP = detectPip(PY)
function run(cmd, label) {
function run(cmd, label, opts = {}) {
console.log(`\n── ${label} ──`)
try {
execSync(cmd, { stdio: "inherit", timeout: 120000 })
execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`)
process.exit(1)
}
return true
}
// ── Self-Healing Module Registry ───────────────────────────────────
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts" },
"hooks/use-media": { template: "hooks/use-media.ts" },
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
}
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
templates = { ...templates, ...(custom.templates || {}) }
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
}
} catch {}
const templateCache = {}
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
resolve(templatesDir, templateName),
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
]
for (const p of candidatePaths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8")
templateCache[templateName] = content
return content
}
}
return ""
}
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
.replace(/\//g, "\\/").replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
const regex = new RegExp(regexStr)
if (regex.test(internalPath)) return templateFile
}
return null
}
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
const content = getTemplate(templateFile)
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
const filePath = resolve(ROOT, "src", internalPath + ext)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, "utf-8")
return { success: true, filePath, message: `Generated @/${internalPath}` }
}
return { generateModule }
}
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
let match
while ((match = errorRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
while ((match = webpackRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
return results
}
// ── Main ───────────────────────────────────────────────────────────
const 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")
// 1. Node dependencies
run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory)
// 2. Python dependencies
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback)
// 3. Playwright browsers
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist
if (!existsSync(".env.local")) {
// 4. .env file
if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local")
copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else {
console.log("\n── .env.local already exists, skipping ──")
}
// 5. Remaining manual steps
console.log("\n── Next steps ──")
// 5. 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
}
}
// 6. Final summary
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings")
+111 -59
View File
@@ -1,82 +1,134 @@
"use client"
import { useState, useCallback } from "react"
import { useState, useCallback, useRef, useEffect } from "react"
import { AIChat } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react"
import { Bot, ChevronRight } from "lucide-react"
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() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job)
}, [])
const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip)
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
}, [])
return (
<div className="flex h-[calc(100vh-3.5rem)]">
<div className="flex h-[calc(100vh-3.5rem)] bg-[#0f1117]">
<div className="flex-1 flex flex-col min-w-0">
<div className="border-b border-[#2a2a35] px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center">
<Bot className="h-5 w-5 text-[#1BB0CE]" />
<div className="bg-[#1a1d2e]/80 backdrop-blur-md border-b border-[#ffffff08] px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-white font-bold text-lg">AI Sales Assistant</h1>
<p className="text-[#6b7280] text-xs mt-0.5">Powered by local AI</p>
</div>
</div>
<div>
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1>
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
<span className="text-[#22c55e] text-xs font-medium">Online</span>
</div>
<div className="w-px h-5 bg-[#ffffff08]" />
<div className="bg-[#ffffff08] rounded-lg px-3 py-1.5">
<span className="text-[#9ca3af] text-xs">Local AI Model</span>
</div>
</div>
</div>
</div>
<div className="flex-1 flex">
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]">
<AIChat />
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
</div>
<div className="w-[300px] flex-none bg-[#13151f] border-l border-[#ffffff08] 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-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div>
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto">
<div>
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Target className="h-3.5 w-3.5" />
Target Job
</h3>
<JobSelector onSelect={handleJobSelect} />
</div>
{selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
</div>
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
{kw}
</span>
))}
</div>
<JobSelector onSelect={handleJobSelect} />
{selectedJob && (
<div className="bg-[#1a1d2e]/50 border border-[#ffffff08] rounded-xl p-3.5 mt-3 space-y-2">
<h4 className="text-sm font-semibold text-[#e5e7eb]">{selectedJob.job_title}</h4>
<span className="text-xs px-2 py-0.5 rounded-md bg-[#f97316]/10 text-[#f97316] font-medium inline-block">{selectedJob.industry}</span>
<p className="text-xs text-[#6b7280] 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-[#ffffff08] text-[#4b5563]">{kw}</span>
))}
</div>
)}
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5">
<Lightbulb className="h-3.5 w-3.5" />
Tips
</h4>
<ul className="space-y-1.5 text-xs text-[#8a8a95]">
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for cold email templates for a specific job
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Request objection handling tips
</li>
<li className="flex gap-2">
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" />
Ask for outreach strategies per industry
</li>
</ul>
</div>
)}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
</div>
{tips.map((tip, i) => (
<div
key={i}
onClick={() => handleTipClick(tip)}
className="flex items-start gap-2.5 bg-[#1a1d2e]/50 hover:bg-[#1a1d2e] border border-[#ffffff08] hover:border-[#f97316]/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
>
<ChevronRight className="h-3.5 w-3.5 mt-0.5 text-[#374151] group-hover:text-[#f97316] transition-colors duration-200 flex-shrink-0" />
<span className="text-[#9ca3af] text-xs leading-5 group-hover:text-[#e5e7eb] transition-colors duration-200">{tip}</span>
</div>
))}
</div>
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
</div>
{recentPrompts.length > 0 ? (
recentPrompts.map((prompt, i) => (
<div
key={i}
onClick={() => handleRecentPromptClick(prompt)}
className="bg-[#1a1d2e]/50 rounded-xl p-3 mb-2 border border-[#ffffff08] text-[#6b7280] text-xs truncate hover:text-[#9ca3af] cursor-pointer transition-colors duration-200"
>
{prompt}
</div>
))
) : (
<div className="text-[#374151] text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-[#ffffff08] pt-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">24</div>
</div>
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">12.4k</div>
</div>
</div>
</div>
+189 -54
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner"
import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote,
Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone,
chat: Video,
follow_up: Clock,
demo: Calendar,
other: Calendar,
website_creation: Globe,
}
function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([])
const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting")
const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json())
.then((data) => setUsers(data.users || []))
.catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router])
useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr))
return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth])
const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null)
setFormTitle("")
setFormDescription("")
setFormType("meeting")
setFormType("website_creation")
setFormDuration("30")
setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00")
@@ -184,37 +196,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "")
setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0])
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }))
setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true)
}
const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) {
toast.error("Title, date, and time are required")
if (!formTitle.trim()) {
toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return
}
setFormSaving(true)
try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = {
title: formTitle.trim(),
description: formDescription.trim() || null,
eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
}
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res
if (editingEvent) {
@@ -235,11 +268,13 @@ export default function CalendarPage() {
const data = await res.json()
if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated")
} else {
setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created")
}
@@ -260,7 +295,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to update")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`)
setDetailEvent(null)
} catch {
@@ -279,7 +314,7 @@ export default function CalendarPage() {
})
if (!res.ok) throw new Error("Failed to save")
const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e))
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
}
@@ -579,7 +614,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]">
<DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className={cn(
@@ -608,11 +643,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div>
<div className="space-y-2">
<div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div>
</div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +673,16 @@ export default function CalendarPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="meeting">Meeting</SelectItem>
<SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10">
<SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -663,6 +712,38 @@ export default function CalendarPage() {
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -690,7 +771,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && (
<DialogContent className="sm:max-w-[460px]">
<DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +792,29 @@ export default function CalendarPage() {
</DialogHeader>
<div className="space-y-4">
{/* Date / Time */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
{detailEvent.startTime ? (
<div className="grid grid-cols-2 gap-3">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div>
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<Clock className="h-3.5 w-3.5" />
Time
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div>
</div>
) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */}
<div className="grid grid-cols-2 gap-3">
@@ -751,11 +839,26 @@ export default function CalendarPage() {
{/* Description */}
{detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p>
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div>
)}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */}
{detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +905,32 @@ export default function CalendarPage() {
</div>
</div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5">
@@ -836,9 +965,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && (
<>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
{detailEvent.developerId === user?.id ? (
<Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button>
+2 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
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,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react"
@@ -792,7 +792,7 @@ const formatPreviewContent = (content: string) => {
})
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 */}
<div
className="flex flex-col border-r shrink-0 overflow-hidden"
@@ -923,9 +923,6 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" />
</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)}>
<CalendarDays className="h-4 w-4" />
</Button>
+11 -8
View File
@@ -24,8 +24,10 @@ import {
SelectValue,
} from "@/components/ui/select"
import { DashboardStats } from "@/types"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() {
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null)
@@ -84,7 +86,7 @@ export default function DashboardPage() {
</PageHeader>
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p>
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats
@@ -95,7 +97,7 @@ export default function DashboardPage() {
}
</div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p>
<p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} />
@@ -104,12 +106,13 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div>
{/* Daily Bugle watermark */}
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
{websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE
</span>
</div>
)}
</div>
)
}
+10 -3
View File
@@ -1,5 +1,7 @@
"use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser()
const router = useRouter()
if (loading) {
useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return (
<div className="flex h-screen items-center justify-center">
<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>
}
+32 -16
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server"
import { NextRequest } from "next/server"
import {
comparePassword,
getUserByEmail,
@@ -10,8 +10,16 @@ import {
isAccountLocked,
createSession,
setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth"
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
export async function POST(request: NextRequest) {
try {
const { email, username, password } = await request.json()
@@ -19,16 +27,16 @@ export async function POST(request: NextRequest) {
const credential = email || username
if (!credential || !password) {
return NextResponse.json(
return jsonResponse(
{ error: "Email/Username and password are required." },
{ status: 400 }
400
)
}
if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json(
return jsonResponse(
{ error: "Credentials cannot be empty." },
{ status: 400 }
400
)
}
@@ -58,9 +66,9 @@ export async function POST(request: NextRequest) {
false,
"User not found"
)
return NextResponse.json(
return jsonResponse(
{ error: "Invalid email/username or password." },
{ status: 401 }
401
)
}
@@ -74,9 +82,9 @@ export async function POST(request: NextRequest) {
false,
lockStatus.reason
)
return NextResponse.json(
return jsonResponse(
{ error: lockStatus.reason },
{ status: 423 }
423
)
}
@@ -91,9 +99,9 @@ export async function POST(request: NextRequest) {
false,
"Invalid password"
)
return NextResponse.json(
return jsonResponse(
{ error: "Invalid email/username or password." },
{ status: 401 }
401
)
}
@@ -106,17 +114,25 @@ export async function POST(request: NextRequest) {
true
)
await createSession(dbUser.id, dbUser.role_name)
const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 })
const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
})
} catch (error) {
console.error("Login error:", error)
return NextResponse.json(
{ error: "Authentication service unavailable." },
{ status: 503 }
return new Response(
JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503, headers: { "Content-Type": "application/json" } }
)
}
}
+11 -7
View File
@@ -1,15 +1,19 @@
import { NextResponse } from "next/server"
import { destroySession } from "@/lib/auth"
import { SESSION_COOKIE } from "@/lib/auth"
export async function POST() {
try {
await destroySession()
return NextResponse.json({ success: true }, { status: 200 })
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
},
})
} catch (error) {
console.error("Logout error:", error)
return NextResponse.json(
{ error: "Logout failed." },
{ status: 500 }
return new Response(
JSON.stringify({ error: "Logout failed." }),
{ status: 500, headers: { "Content-Type": "application/json" } }
)
}
}
+5 -5
View File
@@ -196,11 +196,11 @@ export async function GET(request: NextRequest) {
trends,
recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#FFFFFF" },
{ name: "Contacted", value: currentCounts.contacted, color: "#dc2626" },
{ name: "Pending", value: currentCounts.pending, color: "#1e3a8a" },
{ name: "Closed", value: currentCounts.closed, color: "#60a5fa" },
{ name: "Ignored", value: currentCounts.ignored, color: "#000000" },
{ name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
],
periodLabel: periodLabels[period] ?? "Selected period",
}
+5 -1
View File
@@ -8,8 +8,11 @@ export async function GET() {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email
`SELECT u.id, u.first_name, u.last_name, u.email,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`,
[user.id],
@@ -19,6 +22,7 @@ export async function GET() {
id: r.id,
name: `${r.first_name} ${r.last_name}`,
email: r.email,
role: r.role_display || r.role_name || "",
}))
return NextResponse.json({ users })
+37 -10
View File
@@ -9,11 +9,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json()
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status,
`SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e
@@ -31,8 +31,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const old = existing.rows[0]
const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id
const isDeveloper = old.developer_id === user.id
if (!isCreator && !isParticipant) {
if (!isCreator && !isParticipant && !isDeveloper) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
@@ -42,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
(startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id)
(participantId !== undefined && participantId !== old.participant_id) ||
(developerId !== undefined && developerId !== old.developer_id)
)) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
}
@@ -53,14 +55,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
duration_minutes = COALESCE($7, duration_minutes),
start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id),
developer_id = COALESCE($10, developer_id),
client_name = COALESCE($11, client_name),
client_email = COALESCE($12, client_email),
client_phone = COALESCE($13, client_phone),
updated_at = NOW()
WHERE id = $10
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
WHERE id = $14
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[
title || null,
description ?? null,
@@ -71,6 +77,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
durationMinutes ?? null,
status || null,
participantId ?? null,
developerId ?? null,
clientName ?? null,
clientEmail ?? null,
clientPhone ?? null,
id,
],
user.id,
@@ -78,6 +88,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0]
// Auto-close lead when developer marks event as completed
if (status === "completed" && r.lead_id) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, r.lead_id],
)
}
}
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status
@@ -108,12 +129,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
)
const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
const developerInfo = old.developer_id ? { id: old.developer_id, name: "", email: "" } : null
return NextResponse.json({
event: {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
@@ -126,7 +149,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo,
developer: developerInfo,
lead: null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
},
})
+73 -15
View File
@@ -13,27 +13,33 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end")
const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id,
let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
e.client_name, e.client_email, e.client_phone,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_role_display,
l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e
JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN users d ON d.id = e.developer_id
LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id`
LEFT JOIN roles pr2 ON pr2.id = pr.role_id
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
const params: unknown[] = []
let idx = 1
if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}`
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id)
idx++
} else {
@@ -41,13 +47,13 @@ export async function GET(request: NextRequest) {
}
if (start) {
sql += ` AND e.start_time >= $${idx}`
sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
params.push(start)
idx++
}
if (end) {
sql += ` AND e.start_time <= $${idx}`
sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
params.push(end)
idx++
}
@@ -66,6 +72,7 @@ export async function GET(request: NextRequest) {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
@@ -78,7 +85,11 @@ export async function GET(request: NextRequest) {
status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
}))
@@ -95,36 +106,55 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const {
participantId, leadId, conversationId,
participantId, developerId, leadId, conversationId,
title, description, eventType,
startTime, endTime, durationMinutes,
clientName, clientEmail, clientPhone,
} = await request.json()
if (!title || !startTime) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 })
if (!title) {
return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
}
const result = await query(
`INSERT INTO scheduled_events (user_id, participant_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`,
`INSERT INTO scheduled_events (user_id, participant_id, developer_id, lead_id, conversation_id, title, description, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[
user.id,
participantId || null,
developerId || null,
leadId || null,
conversationId || null,
title,
description || null,
eventType || "meeting",
startTime,
endTime || null,
durationMinutes || null,
eventType || "website_creation",
startTime || null,
eventType === "website_creation" ? null : (endTime || null),
eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null,
clientEmail || null,
clientPhone || null,
],
user.id,
)
const r = result.rows[0]
// Auto-update lead to "pending" when a website creation event is created
if (r.event_type === "website_creation" && leadId) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Qualified'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, leadId],
)
}
}
if (participantId && participantId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
@@ -141,12 +171,35 @@ export async function POST(request: NextRequest) {
)
}
// Notify developer
if (developerId && developerId !== user.id) {
await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
developerId,
"event_scheduled",
`Project assigned: ${title}`,
`${user.firstName} ${user.lastName} assigned a project to you`,
`/calendar`,
r.id,
"scheduled_event",
],
)
}
const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId],
) : null
const participant = participantResult?.rows[0] || null
const developerResult = developerId ? await query(
`SELECT id, first_name, last_name, email FROM users WHERE id = $1`,
[developerId],
) : null
const dev = developerResult?.rows[0] || null
sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email,
@@ -165,6 +218,7 @@ export async function POST(request: NextRequest) {
id: r.id,
userId: r.user_id,
participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id,
conversationId: r.conversation_id,
title: r.title,
@@ -177,7 +231,11 @@ export async function POST(request: NextRequest) {
status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null,
developer: dev ? { id: dev.id, name: `${dev.first_name} ${dev.last_name}`, email: dev.email } : null,
lead: leadId ? { id: leadId, companyName: "", contactName: "" } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at,
},
}, { status: 201 })
+1 -5
View File
@@ -15,11 +15,7 @@ export async function GET(request: NextRequest) {
const pos = searchParams.get("pos") || ""
if (!TENOR_API_KEY) {
return NextResponse.json({
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
return NextResponse.json({ results: [], noKey: true })
}
const endpoint = q
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET() {
[user.id],
)
const websiteTheme = result.rows[0]?.website_theme || "spidey"
const websiteTheme = result.rows[0]?.website_theme || "default"
return NextResponse.json({ websiteTheme })
} catch (error) {
console.error("Website theme GET error:", error)
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json()
const theme = body.websiteTheme || "spidey"
const theme = body.websiteTheme || "default"
await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
+40 -40
View File
@@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
if (!joined) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
</>
) : !isReady ? (
<>
<div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" />
<p className="text-[#888888] text-sm">Connecting to call...</p>
<Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
<p className="text-[#8A9078] text-sm">Connecting to call...</p>
</div>
</>
) : (
<>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
{micError && (
<p className="text-[#CC0000] text-xs mb-4">{micError}</p>
<p className="text-[#C84B4B] text-xs mb-4">{micError}</p>
)}
<input
type="text"
@@ -78,12 +78,12 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
onChange={(e) => setDisplayName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
placeholder="Enter your name"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/>
<button
onClick={handleJoin}
disabled={!displayName.trim()}
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
className="w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 disabled:bg-[#8A9078] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Join Call
@@ -96,14 +96,14 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{callState === "calling" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1>
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
<p className="text-[#8A9078] text-sm animate-pulse">Connecting to call room...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "waiting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1>
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
<p className="text-[#8A9078] text-sm animate-pulse">Share the call link to invite someone...</p>
{participants.length > 0 && (
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<div className="mt-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "participant_joined" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p>
<p className="text-[#8A9078] text-sm animate-pulse">Connecting...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "connecting" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1>
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
<p className="text-[#8A9078] text-sm animate-pulse">Establishing secure connection...</p>
<div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" />
</button>
</div>
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "connected" && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
{participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2">
<div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" />
Participants ({participants.length})
</div>
{participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div>
<div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))}
</div>
)}
<div className="text-[#CC0000] font-mono text-lg mb-6">
<div className="text-[#C84B4B] font-mono text-lg mb-6">
{formatDuration(callDuration)}
</div>
<div className="flex justify-center gap-4">
<button
onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button>
<button
onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
>
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button>
<button
onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
>
<PhoneOff className="h-5 w-5" />
</button>
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{(callState === "ended" || callState === "idle") && (
<div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1>
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
{callDuration > 0 && (
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
<p className="text-[#8A9078] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
)}
</div>
)}
{error && (
<p className="text-[#CC0000] text-xs mt-4">{error}</p>
<p className="text-[#C84B4B] text-xs mt-4">{error}</p>
)}
</div>
</div>
+35 -29
View File
@@ -2,6 +2,8 @@
@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
@import "../styles/ai-assistant.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -76,41 +78,44 @@
}
:root {
--background: 220 14% 84%;
--foreground: 222.2 84% 4.9%;
--card: 220 10% 91%;
--card-foreground: 222.2 84% 4.9%;
--popover: 220 14% 96%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 120 100% 50%;
--background: 60 20% 95%;
--foreground: 71 20% 16%;
--card: 60 29% 97%;
--card-foreground: 71 20% 16%;
--popover: 60 29% 97%;
--popover-foreground: 71 20% 16%;
--primary: 0 53% 54%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 88%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 220 14% 88%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 220 14% 88%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--sidebar: 216 12% 92%;
--sidebar-foreground: 0 0% 20%;
--sidebar-primary: 0 91.2% 59.8%;
--secondary: 210 47% 56%;
--secondary-foreground: 0 0% 100%;
--muted: 75 10% 93%;
--muted-foreground: 75 10% 52%;
--accent: 159 32% 43%;
--accent-foreground: 0 0% 100%;
--destructive: 0 53% 54%;
--destructive-foreground: 0 0% 100%;
--border: 80 13% 82%;
--input: 80 13% 82%;
--ring: 0 53% 54%;
--sidebar: 80 17% 92%;
--sidebar-foreground: 71 20% 16%;
--sidebar-primary: 0 53% 54%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 218 16% 87%;
--sidebar-accent-foreground: 0 0% 20%;
--sidebar-border: 220 11% 84%;
--sidebar-ring: 0 76.3% 48%;
--sidebar-accent: 80 17% 92%;
--sidebar-accent-foreground: 71 20% 16%;
--sidebar-border: 80 13% 82%;
--sidebar-ring: 0 53% 54%;
--radius: 0.5rem;
--theme-primary: hsl(var(--primary));
--event-meeting: 217 91% 55%;
--color-blue: 210 47% 56%;
--color-teal: 159 32% 43%;
--color-red-tint: 0 62% 95%;
--color-blue-tint: 208 54% 93%;
--color-teal-tint: 157 35% 91%;
--color-avatar: 102 21% 52%;
--event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%;
--event-other: 215 20% 55%;
--event-website_creation: 263 70% 50%;
}
.dark {
@@ -731,3 +736,4 @@ main {
.checkbox-text { color: rgba(232,232,239,0.38); }
.footer-text { color: rgba(232,232,239,0.2); }
+12 -12
View File
@@ -7,9 +7,9 @@ interface Props {
function ErrorPage({ message }: { message: string }) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
{message}
</h1>
</div>
@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) {
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
You&apos;re Invited!
</h1>
<p className="text-[#888888] text-sm mb-6">
<p className="text-[#8A9078] text-sm mb-6">
Someone wants to connect with you on our platform.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#888888] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p>
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
</div>
<p className="text-xs text-[#888888] mb-6">
<p className="text-xs text-[#8A9078] mb-6">
Create an account to start making free voice calls to this person and others on the platform.
</p>
<a
href="/register"
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
>
Create Account
</a>
+2 -1
View File
@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner"
import "./globals.css"
import "../../Web_Backgrounds/default-theme.css"
import "../../Web_Backgrounds/spidey-theme.css"
const inter = Inter({
@@ -32,7 +33,7 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange>
<ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
<WebsiteThemeProvider>
{children}
<Toaster />
+16 -2
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react"
@@ -13,7 +13,20 @@ const waves = [
]
export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
@@ -214,7 +227,8 @@ export default function LoginPage() {
})
if (res.ok) {
router.push("/dashboard")
const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else {
const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.")
+244 -152
View File
@@ -1,33 +1,80 @@
"use client"
import { useState, useRef, useEffect, Fragment } from "react"
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react"
import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Bot, Terminal } from "lucide-react"
function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex)
return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a>
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#f97316] hover:text-[#f97316]/80">{part}</a>
}
return <Fragment key={i}>{part}</Fragment>
})
}
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
const trimmed = line.trim()
if (trimmed.startsWith("•") || trimmed.startsWith("-")) {
const content = trimmed.replace(/^[•\-]\s*/, "")
return (
<div key={i} className="flex items-start gap-2.5 my-1.5">
<span className="w-1.5 h-1.5 bg-[#f97316] rounded-sm inline-block mt-2 flex-shrink-0" />
<span>{linkifyText(content)}</span>
</div>
)
}
if (line === "") return <div key={i} className="h-2" />
return <p key={i} className="my-1">{linkifyText(line)}</p>
})
}
interface ChatMessage {
role: "user" | "assistant"
content: string
}
export function AIChat() {
interface AIChatProps {
onMessageSent?: (msg: string) => void
}
const quickActions = [
{ icon: "✉️", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Cold Email Template", desc: "Generate targeted outreach emails", prompt: "Write a cold email template for a Software Developer" },
{ icon: "🛡️", iconBg: "bg-[#3b82f6]/10", iconColor: "text-[#3b82f6]", title: "Handle Objections", desc: "Get scripts for common objections", prompt: "Give me objection handling scripts for sales" },
{ icon: "🎯", iconBg: "bg-[#8b5cf6]/10", iconColor: "text-[#8b5cf6]", title: "Target Industry", desc: "Find leads in specific industries", prompt: "How do I target leads in the tech industry" },
{ icon: "📋", iconBg: "bg-[#22c55e]/10", iconColor: "text-[#22c55e]", title: "Build Lead List", desc: "Strategies to grow your pipeline", prompt: "Help me build a lead list strategy" },
{ icon: "📞", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Call Scripts", desc: "Proven phone sales scripts", prompt: "Give me a cold call script for outreach" },
{ icon: "📊", iconBg: "bg-[#ec4899]/10", iconColor: "text-[#ec4899]", title: "Sales Strategy", desc: "Industry specific sales tactics", prompt: "What sales strategies work best per industry" },
]
const commandPills = [
{ icon: "📋", label: "Lists", prompt: "lists" },
{ icon: "👥", label: "Leads", prompt: "leads" },
{ icon: "💡", label: "Tips", prompt: "Give me some sales tips" },
{ icon: "✉️", label: "Templates", prompt: "Show me email templates" },
]
export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const hasUserMessage = messages.some(m => m.role === "user")
const checkServer = async () => {
useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
setTimeout(() => textareaRef.current?.focus(), 50)
},
}), [])
const checkServer = useCallback(async () => {
try {
const res = await fetch("/api/ai/chat", {
method: "POST",
@@ -42,7 +89,7 @@ export function AIChat() {
} catch {
setTimeout(checkServer, 2000)
}
}
}, [])
useEffect(() => {
fetch("/api/ai/jobs")
@@ -74,18 +121,19 @@ export function AIChat() {
])
})
checkServer()
}, [])
}, [checkServer])
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages])
const sendMessage = async () => {
const msg = input.trim()
const sendMessage = useCallback(async (text?: string) => {
const msg = (text || input).trim()
if (!msg || loading) return
setInput("")
setError("")
onMessageSent?.(msg)
setMessages((prev) => [...prev, { role: "user", content: msg }])
setLoading(true)
@@ -113,167 +161,211 @@ export function AIChat() {
} finally {
setLoading(false)
}
}
}, [input, loading, onMessageSent])
const handleKeyDown = (e: React.KeyboardEvent) => {
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
sendMessage()
}
}, [sendMessage])
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
const handleQuickAction = useCallback((prompt: string) => {
setInput(prompt)
setTimeout(() => sendMessage(prompt), 50)
}, [sendMessage])
const handleCommandPill = useCallback((prompt: string) => {
setInput(prompt)
setTimeout(() => sendMessage(prompt), 50)
}, [sendMessage])
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-5">
<p className="text-sm text-[#6b7280] animate-pulse">Servers booting...</p>
<div className="h-[56px] w-[110px] bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#f97316" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#f97316" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#f97316" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0f1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#f97316" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#f97316" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
}
const bootOverlay = (
<div className="flex-1 flex items-center justify-center">
<style>{`
@keyframes walk {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(12px) translateY(-4px); }
50% { transform: translateX(24px) translateY(0); }
75% { transform: translateX(12px) translateY(-4px); }
}
@keyframes legLeft {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
@keyframes legRight {
0%, 100% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
}
@keyframes armLeft {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
@keyframes armRight {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
@keyframes blink {
0%, 45%, 55%, 100% { height: 4px; }
50% { height: 1px; }
}
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
.robot-eye { animation: blink 2s ease-in-out infinite; }
`}</style>
<div className="flex flex-col items-center gap-4">
<p className="text-sm text-[#6a6a75]">Servers booting...</p>
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/>
</svg>
if (bootState === "ready" && !hasUserMessage) {
return (
<div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="flex-1 flex items-center justify-center">
<div className="max-w-lg mx-auto pt-12 pb-6 px-4">
<div className="float-in">
<div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
<Bot className="h-8 w-8 text-white" />
</div>
<h2 className="text-white font-bold text-2xl text-center mb-2">What can I help you with?</h2>
<p className="text-[#6b7280] text-sm text-center mb-8 leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
</div>
<div className="grid grid-cols-2 gap-3">
{quickActions.map((action, i) => (
<div
key={i}
onClick={() => handleQuickAction(action.prompt)}
className="float-in bg-[#1a1d2e] hover:bg-[#1f2437] rounded-xl p-4 border border-[#ffffff0a] hover:border-[#f97316]/30 cursor-pointer transition-all duration-200 hover:shadow-[0_4px_20px_rgba(249,115,22,0.1)] hover:-translate-y-0.5"
style={{ animationDelay: `${0.1 + i * 0.05}s` }}
>
<div className={`w-9 h-9 rounded-lg ${action.iconBg} ${action.iconColor} flex items-center justify-center text-lg`}>{action.icon}</div>
<h3 className="text-white text-sm font-medium mt-3">{action.title}</h3>
<p className="text-[#6b7280] text-xs mt-1">{action.desc}</p>
</div>
))}
</div>
<div className="flex gap-2 justify-center mt-6 flex-wrap">
{commandPills.map((pill, i) => (
<div
key={i}
onClick={() => handleCommandPill(pill.prompt)}
className="bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/40 hover:bg-[#1f2437] rounded-full px-4 py-2 text-xs text-[#9ca3af] hover:text-[#f97316] transition-all duration-200 cursor-pointer flex items-center gap-1.5"
>
<span>{pill.icon}</span>
<span>{pill.label}</span>
</div>
))}
</div>
</div>
</div>
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
<div className="max-w-3xl mx-auto">
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
</div>
</div>
</div>
</div>
</div>
)
const readyOverlay = (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
<Check className="h-6 w-6 text-green-400" />
</div>
<div className="text-center space-y-1">
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
<div className="flex gap-2">
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
</div>
</div>
</div>
</div>
)
if (bootState === "booting") return bootOverlay
)
}
return (
<div className="flex flex-col h-full">
{bootState === "ready" && readyOverlay}
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin">
{messages.map((msg, i) => (
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{msg.role === "assistant" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
<div className="flex-1 flex flex-col min-h-0">
<div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="max-w-3xl mx-auto space-y-6">
{messages.map((msg, i) => (
<div key={i}>
{msg.role === "assistant" ? (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 max-w-[85%]">
<div className="text-[#e5e7eb] text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
</div>
<div className="text-[#4b5563] text-[10px] mt-1">AI Assistant</div>
</div>
</div>
) : (
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-[#f97316] to-[#ea580c] rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
</div>
</div>
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
</div>
)}
<div
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === "user"
? "bg-[#1BB0CE] text-white"
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
<User className="h-4 w-4 text-white" />
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 inline-flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-1" />
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-2" />
<span className="w-2 h-2 rounded-full bg-[#f97316] dot-3" />
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 justify-start">
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]">
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
</div>
</div>
)}
<div ref={messagesEndRef} />
)}
<div ref={messagesEndRef} />
</div>
</div>
<div className="border-t border-[#2a2a35] p-4">
{error && (
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3 w-3" />
{error}
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
<div className="max-w-3xl mx-auto">
{error && (
<div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
<span></span>
{error}
</div>
)}
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
</div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
/>
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
>
{loading ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
) : <Send className="h-4 w-4" />}
</button>
</div>
</div>
</div>
)
}
})
AIChat.displayName = "AIChat"
+7 -7
View File
@@ -39,32 +39,32 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
<button
type="button"
onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors"
className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
>
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" />
<Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />}
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto">
<div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
{jobs.map((job, i) => (
<button
key={i}
type="button"
onClick={() => handleSelect(job)}
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0"
className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/40"
>
<div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} {job.description}</div>
<div className="text-xs text-[#4b5563] mt-0.5">{job.industry} &mdash; {job.description}</div>
</button>
))}
{jobs.length === 0 && !loading && (
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div>
<div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
)}
</div>
</>
+15 -7
View File
@@ -39,8 +39,9 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false)
const [gifUnavailable, setGifUnavailable] = useState(false)
const [gifNext, setGifNext] = useState("")
const gifCache = useRef<Map<string, { results: any[]; next: string }>>(new Map())
const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
@@ -60,6 +61,13 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => {
const cacheKey = `${query}::${pos}`
const cached = gifCache.current.get(cacheKey)
if (cached && !pos) {
setGifResults(cached.results)
setGifNext(cached.next)
return
}
setGifLoading(true)
setGifError("")
try {
@@ -68,17 +76,18 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const res = await fetch(`/api/gifs?${params}`)
const data = await res.json()
if (data.noKey) {
setGifNoKey(true)
setGifUnavailable(true)
setGifResults([])
} else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "")
setGifNoKey(false)
setGifUnavailable(false)
if (!pos) gifCache.current.set(cacheKey, { results: data.results, next: data.next || "" })
} else {
setGifError("No results found")
}
} catch {
setGifError("Failed to load GIFs")
setGifError("Could not load GIFs. Check your connection.")
}
setGifLoading(false)
}, [])
@@ -196,11 +205,10 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : gifNoKey ? (
) : gifUnavailable ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
<span>GIFs are temporarily unavailable.</span>
</div>
) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
+34 -34
View File
@@ -162,11 +162,11 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<button
type="button"
onClick={onClose}
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
className="absolute top-4 right-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
@@ -174,27 +174,27 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
{callLink ? (
shareSent ? (
<>
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
<p className="text-sm text-[#4A9078] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Waiting for participant</h2>
<p className="text-[#8A9078] text-sm mt-1 mb-4">
The recipient must receive and open the call link.
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
<div className="bg-card dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-foreground dark:text-white">
{callLink}
</div>
<div className="flex gap-2">
<button
onClick={copyLink}
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
className="flex-1 flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
</button>
<button
onClick={() => { setShareSent(false); setShareError(null) }}
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
className="flex-1 flex items-center justify-center gap-2 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<MessageSquare className="h-4 w-4" />
WhatsApp
@@ -205,24 +205,24 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
href={callLink}
target="_blank"
rel="noopener noreferrer"
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
className="block w-full mt-4 bg-muted-foreground hover:bg-muted-foreground/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
>
Open Call
</a>
</>
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#888888] text-sm mt-1 mb-4">
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#8A9078] text-sm mt-1 mb-4">
Select a method to send the call link
</p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white">
<div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#2D3020] dark:text-white">
{callLink}
</div>
{showWaUrl && (
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono">
<div className="bg-card dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#4A9078] font-mono">
{showWaUrl}
</div>
)}
@@ -237,7 +237,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</button>
<button
onClick={copyLink}
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
className="flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
>
<Copy className="h-4 w-4" />
Copy URL
@@ -245,16 +245,16 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div>
{shareError && (
<p className="text-[#CC0000] text-xs text-center">{shareError}</p>
<p className="text-[#C84B4B] text-xs text-center">{shareError}</p>
)}
</>
)
) : (
<>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2>
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Start a Call</h2>
<p className="text-[#8A9078] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
CONTACTS
</p>
@@ -262,25 +262,25 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search contacts..."
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/>
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
{contactsLoading && (
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
<p className="text-[#8A9078] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
)}
{contactsError && (
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p>
<p className="text-[#C84B4B] text-sm text-center py-4">Could not load contacts</p>
)}
{!contactsLoading && !contactsError && filteredContacts.length === 0 && (
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p>
<p className="text-[#8A9078] text-sm text-center py-4">No contacts found</p>
)}
{!contactsLoading && !contactsError && filteredContacts.map((contact) => (
<div
key={contact.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#FAFAF6] dark:hover:bg-[#1A1A1A]"
>
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]">
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FAEAEA] dark:bg-[#CC0000]/15 text-[#C84B4B] dark:text-[#FF4444]">
{contact.avatar_url ? (
<img
src={contact.avatar_url}
@@ -292,13 +292,13 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p>
<p className="text-sm font-medium text-[#2D3020] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#8A9078] dark:[#666666] truncate">{contact.phone}</p>
</div>
<button
type="button"
onClick={() => handleCall(contact.phone)}
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
className="shrink-0 text-[#C84B4B] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
>
<Phone className="h-4 w-4" />
</button>
@@ -307,12 +307,12 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div>
<div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" />
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
<span className="text-xs text-[#8A9078] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
</div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2">
<p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
DIAL A NUMBER
</p>
@@ -321,10 +321,10 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={phoneNumber}
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
placeholder="+27 000 000 0000"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]"
className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/>
{phoneError && (
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p>
<p className="text-[#C84B4B] text-xs mt-1">Please enter a phone number</p>
)}
<button
@@ -337,7 +337,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
setPhoneError(false)
handleCall(trimmed)
}}
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
className="w-full mt-3 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
>
<Phone className="h-4 w-4" />
Call Now
@@ -21,14 +21,14 @@ export function CrossedLightsabers() {
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
<g className="animate-pulse-red">
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-outer" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-mid" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
</g>
<g className="animate-pulse-blue">
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-outer" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-mid" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
</g>
@@ -98,7 +98,7 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`}
stroke="#d4d4d4"
stroke="#000000"
strokeWidth="1"
opacity={hov !== null && !isH ? 0.3 : 1}
style={{
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
data: IntervalData[]
}
const NEW_LEADS = "#CC0000"
const CLOSED = "#0033CC"
const NEW_LEADS = "#C84B4B"
const CLOSED = "#5A8FC4"
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear())
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
>
<defs>
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FF1111" />
<stop offset="0%" stopColor="#C84B4B" />
<stop offset="100%" stopColor={NEW_LEADS} />
</linearGradient>
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1144FF" />
<stop offset="0%" stopColor="#5A8FC4" />
<stop offset="100%" stopColor={CLOSED} />
</linearGradient>
<filter id="shadowNew">
@@ -9,11 +9,11 @@ import { Lead } from "@/types"
import { ArrowRight } from "lucide-react"
const statusStyles: Record<string, string> = {
open: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50",
contacted: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
pending: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20",
closed: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20",
ignored: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40",
open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
}
interface RecentLeadsTableProps {
@@ -23,10 +23,10 @@ export function StatCardSkeleton() {
return (
<div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm">
<p className="text-[#8A9078] dark:text-[#AAAAAA] text-sm">
Loading your data...
</p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4">
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#D4D8CC] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
</div>
</div>
+27 -23
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { Card, CardContent } from "@/components/ui/card"
import { LucideIcon } from "lucide-react"
@@ -19,12 +20,12 @@ interface StatCardProps {
}
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" },
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" },
"Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
}
function computeGoal(max: number): number {
@@ -50,6 +51,7 @@ function smoothPath(points: { x: number; y: number }[]): string {
}
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
const { websiteTheme } = useWebsiteTheme()
const color = cardColors[title] ?? cardColors["Total Leads"]
const isNumeric = typeof value === "number"
@@ -108,8 +110,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const sparkColor = color.accent
const isRed = index % 2 === 0
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]"
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20"
const stripColor = isRed ? "from-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
return (
<motion.div
@@ -119,28 +121,30 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
className="relative"
>
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */}
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
{websiteTheme === "spidey" && (
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
)}
{/* Action lines on hover */}
<div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
{websiteTheme === "spidey" && (
<div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cg stroke='%23CC0000' stroke-width='0.4' opacity='0.15'%3E%3Cline x1='100' y1='100' x2='0' y2='0'/%3E%3Cline x1='100' y1='100' x2='50' y2='0'/%3E%3Cline x1='100' y1='100' x2='100' y2='0'/%3E%3Cline x1='100' y1='100' x2='150' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='0'/%3E%3Cline x1='100' y1='100' x2='200' y2='50'/%3E%3Cline x1='100' y1='100' x2='200' y2='100'/%3E%3Cline x1='100' y1='100' x2='200' y2='150'/%3E%3Cline x1='100' y1='100' x2='200' y2='200'/%3E%3Cline x1='100' y1='100' x2='150' y2='200'/%3E%3Cline x1='100' y1='100' x2='100' y2='200'/%3E%3Cline x1='100' y1='100' x2='50' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='200'/%3E%3Cline x1='100' y1='100' x2='0' y2='150'/%3E%3Cline x1='100' y1='100' x2='0' y2='100'/%3E%3Cline x1='100' y1='100' x2='0' y2='50'/%3E%3C/g%3E%3C/svg%3E")`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
)}
<CardContent className="p-6 flex flex-col relative z-[1]">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white">
<p className="text-3xl font-bold tracking-tight text-[#2D3020] dark:text-white">
{isNumeric ? display : value}
</p>
{description && (
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p>
<p className="text-xs text-[#8A9078] dark:text-[#666666]">{description}</p>
)}
{trend && (
<span className={cn(
@@ -196,9 +200,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
{title === "Conversion Rate" && conversionRate !== undefined && (
<div className="mt-3">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
<div className="h-full rounded-full bg-gradient-to-r from-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div>
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
<p className="text-xs text-[#8A9078] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
</div>
)}
</CardContent>
+91 -40
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react"
import { usePathname } from "next/navigation"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { motion, AnimatePresence } from "framer-motion"
import { Sidebar } from "./sidebar"
import { Topbar } from "./topbar"
@@ -11,6 +12,7 @@ interface AppShellProps {
}
export function AppShell({ children }: AppShellProps) {
const { websiteTheme } = useWebsiteTheme()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname()
@@ -33,51 +35,100 @@ export function AppShell({ children }: AppShellProps) {
}
return (
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={{
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={websiteTheme === "spidey" ? {
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px",
}}
} : {}}
>
{/* Spider-Man top gradient bar */}
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
{/* Spider-Man decorations — Spidey theme only */}
{websiteTheme === "spidey" && (
<>
<div className="fixed top-0 left-0 right-0 h-[3px] w-full bg-gradient-to-r from-[#CC0000] via-[#FFFFFF] to-[#0033CC] dark:from-[#FF1111] dark:via-[#FFFFFF] dark:to-[#1144FF] z-50" />
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
</>
)}
{/* Corner spider webs */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]">
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/>
</svg>
</div>
{/* Cosmic decorations */}
{websiteTheme === "cosmic" && (
<>
<div className="fixed top-0 left-0 right-0 h-[2px] w-full bg-gradient-to-r from-transparent via-[#00CCB3] to-transparent dark:via-[#00CCB3] z-50 opacity-60" />
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.12]">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="40" cy="30" r="2" fill="#00CCB3" opacity="0.8"/>
<circle cx="85" cy="65" r="1.5" fill="#a855f7" opacity="0.6"/>
<circle cx="25" cy="120" r="1" fill="#00CCB3" opacity="0.5"/>
<circle cx="60" cy="170" r="2.5" fill="#a855f7" opacity="0.7"/>
<circle cx="120" cy="25" r="1.5" fill="#00CCB3" opacity="0.6"/>
<circle cx="160" cy="80" r="1" fill="#a855f7" opacity="0.4"/>
<circle cx="140" cy="150" r="2" fill="#00CCB3" opacity="0.7"/>
<circle cx="180" cy="40" r="1.5" fill="#a855f7" opacity="0.5"/>
<circle cx="50" cy="90" r="3" fill="#00CCB3" opacity="0.08"/>
<circle cx="130" cy="110" r="4" fill="#a855f7" opacity="0.06"/>
<path d="M40,30 Q60,50 85,65" stroke="#00CCB3" strokeWidth="0.4" opacity="0.25"/>
<path d="M85,65 Q100,120 60,170" stroke="#a855f7" strokeWidth="0.4" opacity="0.2"/>
<path d="M25,120 Q80,140 140,150" stroke="#00CCB3" strokeWidth="0.4" opacity="0.2"/>
<path d="M120,25 Q150,50 160,80" stroke="#a855f7" strokeWidth="0.3" opacity="0.2"/>
</svg>
</div>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.12]">
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<circle cx="40" cy="30" r="2" fill="#a855f7" opacity="0.8"/>
<circle cx="85" cy="65" r="1.5" fill="#00CCB3" opacity="0.6"/>
<circle cx="25" cy="120" r="1" fill="#a855f7" opacity="0.5"/>
<circle cx="60" cy="170" r="2.5" fill="#00CCB3" opacity="0.7"/>
<circle cx="120" cy="25" r="1.5" fill="#a855f7" opacity="0.6"/>
<circle cx="160" cy="80" r="1" fill="#00CCB3" opacity="0.4"/>
<circle cx="140" cy="150" r="2" fill="#a855f7" opacity="0.7"/>
<circle cx="180" cy="40" r="1.5" fill="#00CCB3" opacity="0.5"/>
<circle cx="50" cy="90" r="3" fill="#a855f7" opacity="0.08"/>
<circle cx="130" cy="110" r="4" fill="#00CCB3" opacity="0.06"/>
<path d="M40,30 Q60,50 85,65" stroke="#a855f7" strokeWidth="0.4" opacity="0.25"/>
<path d="M85,65 Q100,120 60,170" stroke="#00CCB3" strokeWidth="0.4" opacity="0.2"/>
<path d="M25,120 Q80,140 140,150" stroke="#a855f7" strokeWidth="0.4" opacity="0.2"/>
<path d="M120,25 Q150,50 160,80" stroke="#00CCB3" strokeWidth="0.3" opacity="0.2"/>
</svg>
</div>
</>
)}
</svg>
</div>
</>
)}
<Sidebar
collapsed={sidebarCollapsed}
+3 -3
View File
@@ -8,17 +8,17 @@ export function CrmIcon() {
viewBox="0 0 64 64"
aria-hidden="true"
>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
</g>
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
<g className="fill-[#8A9078] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
</g>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
<g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
+1 -1
View File
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
return (
<svg
viewBox="0 0 192.756 192.756"
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto"
className="fill-[#2D3020] dark:fill-[#e5e7eb] w-full h-auto"
aria-hidden="true"
>
<g fill-rule="evenodd" clip-rule="evenodd">
+1 -1
View File
@@ -62,7 +62,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
>
{/* Logo */}
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
<Link href="/" className="flex items-center gap-3 overflow-hidden">
<Link href="/dashboard" className="flex items-center gap-3 overflow-hidden">
{collapsed ? (
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
) : (
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"
const RAM_LIMIT_MB = 8192
const RAM_LIMIT_MB = 16000
const CPU_LIMIT_PCT = 400
interface SystemMonitorProps {
+10 -38
View File
@@ -33,6 +33,7 @@ import {
Trash2,
} from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
interface TopbarProps {
onMenuClick: () => void;
@@ -72,40 +73,11 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase();
return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden">
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
{/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" && (
<>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="dark:hidden shrink-0">
<circle cx="14" cy="14" r="14" fill="white"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#CC0000"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#CC0000"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#CC0000" strokeWidth="1.2"/>
</svg>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" className="hidden dark:block shrink-0">
<circle cx="14" cy="14" r="14" fill="#1A1A1A"/>
<ellipse cx="14" cy="14" rx="4" ry="5" fill="#FF1111"/>
<ellipse cx="14" cy="14" rx="2" ry="2.5" fill="#FF1111"/>
<line x1="14" y1="9" x2="6" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="9" x2="22" y2="5" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="5" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="12" x2="23" y2="11" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="6" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="19" x2="22" y2="23" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="5" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
<line x1="14" y1="16" x2="23" y2="17" stroke="#FF1111" strokeWidth="1.2"/>
</svg>
</>
)}
<span className="text-[#CC0000] dark:text-[#FF1111] font-bold text-sm tracking-wide">
{websiteTheme === "spidey" && <CaptainAmericaShield />}
<span className="text-primary font-bold text-sm tracking-wide">
CRM
</span>
</div>
@@ -122,10 +94,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground dark:text-[#666666]" />
<Input
placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white"
className="h-9 w-full max-w-sm pl-9 bg-muted dark:bg-[#1E1E1E] border-border dark:border-[#222222] text-foreground dark:text-white"
/>
</div>
@@ -145,7 +117,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-muted-foreground"
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
>
{mounted ? (
theme === "dark" ? (
@@ -163,7 +135,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost"
size="icon"
onClick={() => setBugModalOpen(true)}
className="text-muted-foreground"
className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
title="Report a Bug"
>
<Bug className="h-5 w-5" />
@@ -175,7 +147,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Button
variant="ghost"
size="icon"
className="relative text-muted-foreground"
className="relative text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
>
<Bell className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
@@ -245,7 +217,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* User profile */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3">
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3 hover:bg-[#8b949e]/10 hover:text-[#8b949e]">
<Avatar className="h-7 w-7">
<AvatarImage src={user.avatar} />
<AvatarFallback>{initials}</AvatarFallback>
+10 -10
View File
@@ -7,23 +7,23 @@ import { cn } from "@/lib/utils"
const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
open: {
label: "Open",
class: "bg-white/40 text-gray-700 dark:text-gray-300 border-gray-300/50 dark:border-gray-600/50",
class: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
},
contacted: {
label: "Contacted",
class: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20",
class: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
},
pending: {
label: "Pending",
class: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20",
class: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
},
closed: {
label: "Closed",
class: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20",
class: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
},
ignored: {
label: "Ignored",
class: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40",
class: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
},
}
@@ -37,11 +37,11 @@ export function LeadStatusBadge({ status, className }: LeadStatusBadgeProps) {
return (
<Badge variant="outline" className={cn(config.class, className)}>
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", {
"bg-white border border-gray-300 dark:border-gray-500": status === "open",
"bg-red-500": status === "contacted",
"bg-blue-900": status === "pending",
"bg-blue-400": status === "closed",
"bg-black": status === "ignored",
"bg-blue-500": status === "open",
"bg-amber-500": status === "contacted",
"bg-purple-500": status === "pending",
"bg-emerald-500": status === "closed",
"bg-zinc-500": status === "ignored",
})} />
{config.label}
</Badge>
+4 -1
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Shield } from "lucide-react"
import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
const COLOR_THEME_KEY = "crm-color-theme"
@@ -31,7 +31,10 @@ const themeOptions = [
]
const backgroundOptions = [
{ value: "default", label: "None", icon: Shield, color: "bg-gray-400", ring: "ring-gray-400", desc: "No background theme" },
{ value: "spidey", label: "Spidey", icon: Shield, color: "bg-red-600", ring: "ring-red-600", desc: "Dark theme with red accents" },
{ value: "cosmic", label: "Cosmic", icon: Shield, color: "bg-gradient-to-r from-purple-500 to-green-500", ring: "ring-purple-500", desc: "Deep space with purple nebula and cyan accents" },
{ value: "cyber", label: "Cyber", icon: Shield, color: "bg-gradient-to-r from-cyan-400 to-fuchsia-500", ring: "ring-cyan-400", desc: "Neon grid with magenta and cyan glow" },
]
function getStoredColorTheme(): string {
+19 -19
View File
@@ -75,7 +75,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
>
<button
onClick={handleClose}
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors"
className="absolute right-4 top-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
>
<X className="h-5 w-5" />
</button>
@@ -83,15 +83,15 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
{submitted ? (
<div className="flex flex-col items-center py-8 text-center">
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2">
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white mb-2">
Bug Report Submitted
</h3>
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]">
<p className="text-sm text-[#8A9078] dark:text-[#AAAAAA]">
Thank you for your report. The development team will investigate.
</p>
<button
onClick={handleClose}
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors"
className="mt-6 rounded-lg bg-[#C84B4B] px-6 py-2 text-sm font-medium text-white hover:bg-[#C84B4B]/80 transition-colors"
>
Done
</button>
@@ -99,14 +99,14 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
) : (
<>
<div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30">
<Bug className="h-5 w-5 text-[#CC0000]" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#FAEAEA] dark:bg-red-900/30">
<Bug className="h-5 w-5 text-[#C84B4B]" />
</div>
<div>
<h3 className="text-lg font-semibold text-[#111111] dark:text-white">
<h3 className="text-lg font-semibold text-[#2D3020] dark:text-white">
Report a Bug
</h3>
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]">
<p className="text-xs text-[#8A9078] dark:text-[#AAAAAA]">
Help us improve the system
</p>
</div>
@@ -120,8 +120,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
Title <span className="text-[#CC0000]">*</span>
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Title <span className="text-[#C84B4B]">*</span>
</label>
<input
type="text"
@@ -129,13 +129,13 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief description of the issue"
required
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
Description <span className="text-[#CC0000]">*</span>
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Description <span className="text-[#C84B4B]">*</span>
</label>
<textarea
value={description}
@@ -143,18 +143,18 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
placeholder="What happened? What did you expect?"
rows={4}
required
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
className="w-full resize-none rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]">
<label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Severity
</label>
<select
value={severity}
onChange={(e) => setSeverity(e.target.value)}
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]"
className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
>
<option value="low">Low Minor cosmetic issue</option>
<option value="medium">Medium Affects functionality</option>
@@ -163,8 +163,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
</select>
</div>
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2">
<p className="text-xs text-[#888888]">
<div className="rounded-lg bg-[#FAFAF6] dark:bg-[#1A1A1A] px-3 py-2">
<p className="text-xs text-[#8A9078]">
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
</p>
</div>
@@ -172,7 +172,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<button
type="submit"
disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors"
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#C84B4B] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#C84B4B]/80 disabled:opacity-50 transition-colors"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Submitting..." : "Submit Bug Report"}
@@ -8,24 +8,11 @@ export function CaptainAmericaShield() {
viewBox="0 0 100 100"
aria-hidden="true"
>
<defs>
<clipPath id="shield-clip">
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" />
</clipPath>
</defs>
<g clipPath="url(#shield-clip)">
<circle cx="50" cy="45" r="45" fill="#c62828" />
<circle cx="50" cy="45" r="35" fill="#efefef" />
<circle cx="50" cy="45" r="25" fill="#c62828" />
<circle cx="50" cy="45" r="15" fill="#1565c0" />
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
</g>
<polygon
points="50,95 5,60 5,30 50,5 95,30 95,60"
fill="none"
stroke="#c62828"
strokeWidth="1.5"
/>
<circle cx="50" cy="50" r="48" fill="#c62828" />
<circle cx="50" cy="50" r="36" fill="#efefef" />
<circle cx="50" cy="50" r="24" fill="#c62828" />
<circle cx="50" cy="50" r="14" fill="#1565c0" />
<polygon points="50,34 55,48 70,48 58,57 62,72 50,62 38,72 42,57 30,48 45,48" fill="#efefef" />
</svg>
)
}
+2 -2
View File
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
className={cn("flex items-center justify-between pb-6", className)}
>
<div>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1>
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>}
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-[#FF1111] pl-4 text-[#2D3020] dark:text-white">{title}</h1>
{description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
</div>
{children && <div className="flex items-center gap-3">{children}</div>}
</motion.div>
+4 -4
View File
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
})
return [
{ name: "Open", value: statusCounts.open, color: "#CC0000" },
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" },
{ name: "Open", value: statusCounts.open, color: "#C84B4B" },
{ name: "Contacted", value: statusCounts.contacted, color: "#5A8FC4" },
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" },
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" },
{ name: "Closed", value: statusCounts.closed, color: "#5A8FC4" },
{ name: "Ignored", value: statusCounts.ignored, color: "#C84B4B" },
]
}
+13 -26
View File
@@ -3,13 +3,21 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db";
import { cookies } from "next/headers";
const RAW_SECRET = process.env.JWT_SECRET;
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required");
function randomHex(length: number): string {
const chars = "abcdef0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += chars[Math.floor(Math.random() * chars.length)];
}
return result;
}
const g = globalThis as typeof globalThis & { __JWT_RAW_SECRET?: string };
const RAW_SECRET = process.env.JWT_SECRET || (g.__JWT_RAW_SECRET ??= randomHex(32));
if (!RAW_SECRET) throw new Error("JWT_SECRET environment variable is required");
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session";
export const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15;
@@ -195,28 +203,7 @@ export function mapDbUserToSessionUser(
}
export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role });
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return token;
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 0,
});
return signToken({ userId, role });
}
export async function encryptPassword(password: string): Promise<string> {
+5 -5
View File
@@ -1,9 +1,9 @@
export const LEAD_STATUSES = {
open: { label: "Open", color: "bg-white" },
contacted: { label: "Contacted", color: "bg-red-500" },
pending: { label: "Pending", color: "bg-blue-900" },
closed: { label: "Closed", color: "bg-blue-400" },
ignored: { label: "Ignored", color: "bg-black" },
open: { label: "Open", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-amber-500" },
pending: { label: "Pending", color: "bg-purple-500" },
closed: { label: "Closed", color: "bg-emerald-500" },
ignored: { label: "Ignored", color: "bg-zinc-500" },
} as const
export const LEAD_SOURCES = [
+1 -1
View File
@@ -74,7 +74,7 @@ function buildEmail(event: EventConfirmationInput, variant: "scheduled" | "resch
<html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px">
<div style="max-width:520px;margin:0 auto;background:#fff;border-radius:12px;overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,0.08)">
<div style="background:linear-gradient(135deg,#CC0000,#0033CC);padding:24px 32px">
<div style="background:linear-gradient(135deg,#C84B4B,#5A8FC4);padding:24px 32px">
<h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
</div>
<div style="padding:32px">
+1 -18
View File
@@ -1,12 +1,5 @@
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
import { jwtVerify } from "jose"
const RAW_SECRET = process.env.JWT_SECRET
if (!RAW_SECRET) {
throw new Error("JWT_SECRET environment variable is required")
}
const JWT_SECRET = new TextEncoder().encode(RAW_SECRET)
const publicRoutes = [
"/login",
@@ -41,17 +34,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl)
}
try {
await jwtVerify(sessionCookie, JWT_SECRET)
return NextResponse.next()
} catch {
if (pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const loginUrl = new URL("/login", request.url)
loginUrl.searchParams.set("redirect", pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
+6 -3
View File
@@ -5,7 +5,10 @@ import { createContext, useContext, useState, useEffect, ReactNode, useCallback
const WEBSITE_THEME_KEY = "crm-website-theme"
const themeClasses: Record<string, string> = {
default: "theme-default",
spidey: "theme-spidey",
cosmic: "theme-cosmic",
cyber: "theme-cyber",
}
interface WebsiteThemeContextValue {
@@ -38,7 +41,7 @@ function storeTheme(theme: string) {
}
export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey")
const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
const [loading, setLoading] = useState(true)
useEffect(() => {
@@ -52,13 +55,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
const theme = data?.websiteTheme || "spidey"
const theme = data?.websiteTheme || "default"
setWebsiteThemeState(theme)
storeTheme(theme)
applyWebsiteTheme(theme)
})
.catch(() => {
applyWebsiteTheme("spidey")
applyWebsiteTheme("default")
})
.finally(() => setLoading(false))
}, [])
+61
View File
@@ -0,0 +1,61 @@
/* ============================================================================
AI Assistant Page Animations & Keyframes
============================================================================ */
/* Robot walking animation (boot state) */
@keyframes walk {
0%, 100% { transform: translateX(0) translateY(0); }
25% { transform: translateX(12px) translateY(-4px); }
50% { transform: translateX(24px) translateY(0); }
75% { transform: translateX(12px) translateY(-4px); }
}
@keyframes legLeft {
0%, 100% { transform: rotate(-10deg); }
50% { transform: rotate(10deg); }
}
@keyframes legRight {
0%, 100% { transform: rotate(10deg); }
50% { transform: rotate(-10deg); }
}
@keyframes armLeft {
0%, 100% { transform: rotate(15deg); }
50% { transform: rotate(-15deg); }
}
@keyframes armRight {
0%, 100% { transform: rotate(-15deg); }
50% { transform: rotate(15deg); }
}
@keyframes blink {
0%, 45%, 55%, 100% { height: 4px; }
50% { height: 1px; }
}
.robot-walk { animation: walk 0.6s ease-in-out infinite; }
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; }
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; }
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; }
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; }
.robot-eye { animation: blink 2s ease-in-out infinite; }
/* Welcome screen float-in animation */
@keyframes float-in {
0% { opacity: 0; transform: translateY(12px); }
100% { opacity: 1; transform: translateY(0); }
}
.float-in { animation: float-in 0.4s ease-out both; }
/* Typing indicator bouncing dots */
@keyframes bounce-dot {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
.dot-1 { animation: bounce-dot 1.2s infinite 0ms; }
.dot-2 { animation: bounce-dot 1.2s infinite 150ms; }
.dot-3 { animation: bounce-dot 1.2s infinite 300ms; }
+6 -1
View File
@@ -110,13 +110,14 @@ export interface Conversation {
messages: ChatMessage[];
}
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other";
export type EventType = "call" | "follow_up" | "website_creation";
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent {
id: string;
userId: string;
participantId: string | null;
developerId: string | null;
leadId: string | null;
conversationId: string | null;
title: string;
@@ -129,6 +130,10 @@ export interface CalendarEvent {
status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string;
}
+6
View File
@@ -53,6 +53,12 @@ const config: Config = {
border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))",
},
blue: "hsl(var(--color-blue))",
teal: "hsl(var(--color-teal))",
"red-tint": "hsl(var(--color-red-tint))",
"blue-tint": "hsl(var(--color-blue-tint))",
"teal-tint": "hsl(var(--color-teal-tint))",
avatar: "hsl(var(--color-avatar))",
},
borderRadius: {
lg: "var(--radius)",