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 @@
/* ============================================================================ @import url("./cosmic-theme.css");
Spidey Theme — Baseline Design Tokens @import url("./cyber-theme.css");
This is the current website appearance captured as a reusable CSS theme.
============================================================================ */
.theme-spidey { .dark.theme-spidey {
--background: 222.2 84% 4.9%; --background: 222.2 84% 4.9%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222.2 84% 4.9%; --card: 222.2 84% 4.9%;
@@ -34,7 +32,38 @@
--sidebar-ring: 0 100% 53%; --sidebar-ring: 0 100% 53%;
} }
.theme-spidey body::before { .light.theme-spidey {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 0 100% 45%;
--primary-foreground: 0 0% 100%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 0 100% 45%;
--radius: 0.5rem;
--sidebar: 0 0% 100%;
--sidebar-foreground: 222.2 84% 4.9%;
--sidebar-primary: 0 100% 45%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 210 40% 96%;
--sidebar-accent-foreground: 222.2 47.4% 11.2%;
--sidebar-border: 214.3 31.8% 91.4%;
--sidebar-ring: 0 100% 45%;
}
.dark.theme-spidey body::before {
content: ""; content: "";
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -67,3 +96,86 @@
background-size: 200% 200%; background-size: 200% 200%;
animation: drift 60s linear infinite; animation: drift 60s linear infinite;
} }
.light.theme-spidey body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
repeating-conic-gradient(
from 10deg,
transparent 0deg 10deg,
rgba(220, 38, 38, 0.03) 10deg 12deg
),
repeating-radial-gradient(
circle at 50% 50%,
transparent 0,
transparent 30px,
rgba(37, 99, 235, 0.02) 30px,
transparent 32px
),
radial-gradient(1.5px 1.5px at 10% 20%, rgba(220,38,38,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 30% 80%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 50% 30%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 70% 60%, rgba(37,99,235,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 85% 15%, rgba(220,38,38,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 20% 50%, rgba(37,99,235,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 65% 85%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 40% 10%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 90% 75%, rgba(220,38,38,0.15) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 5% 60%, rgba(37,99,235,0.2) 0%, transparent 100%),
radial-gradient(1.5px 1.5px at 55% 55%, rgba(220,38,38,0.1) 0%, transparent 100%);
background-size: 200% 200%;
animation: drift 60s linear infinite;
}
@keyframes drift {
0% { background-position: 0% 0%; }
100% { background-position: 100% 100%; }
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(0, 100%, 53%), hsl(0, 100%, 65%), hsl(0, 100%, 53%)) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(217, 91%, 60%), hsl(217, 91%, 70%), hsl(217, 91%, 60%)) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 {
background: hsla(0, 100%, 53%, 0.1) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 {
background: hsla(217, 91%, 60%, 0.1) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .rounded-xl.shrink-0 svg {
color: hsl(0, 100%, 53%) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .rounded-xl.shrink-0 svg {
color: hsl(217, 91%, 60%) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(0, 100%, 53%, 0.25), transparent) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .absolute.bottom-0.h-1 {
background: linear-gradient(to right, transparent, hsla(217, 91%, 60%, 0.25), transparent) !important;
}
.dark.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 .rounded-full.bg-gradient-to-r {
background: linear-gradient(to right, hsl(0, 100%, 53%), hsl(217, 91%, 60%)) !important;
}
.light.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(odd) .group .h-1.w-full {
background: linear-gradient(to right, hsl(0, 100%, 45%), hsl(0, 100%, 55%), hsl(0, 100%, 45%)) !important;
}
.light.theme-spidey .sm\:grid-cols-2.lg\:grid-cols-3.gap-4.mt-4 > .relative:nth-child(even) .group .h-1.w-full {
background: linear-gradient(to right, hsl(217, 91%, 50%), hsl(217, 91%, 60%), hsl(217, 91%, 50%)) !important;
}
+71 -36
View File
@@ -2,38 +2,73 @@
"theme": { "theme": {
"id": "spidey", "id": "spidey",
"name": "Spidey", "name": "Spidey",
"description": "Current website appearance — dark theme with red accents", "description": "Dark mode with red accents and web patterns; light mode with clean red-blue accents",
"type": "dark", "type": "dual",
"default": true "default": true
}, },
"colors": { "modes": {
"background": "hsl(222.2, 84%, 4.9%)", "dark": {
"foreground": "hsl(210, 40%, 98%)", "colors": {
"card": "hsl(222.2, 84%, 4.9%)", "background": "hsl(222.2, 84%, 4.9%)",
"cardForeground": "hsl(210, 40%, 98%)", "foreground": "hsl(210, 40%, 98%)",
"popover": "hsl(222.2, 84%, 4.9%)", "card": "hsl(222.2, 84%, 4.9%)",
"popoverForeground": "hsl(210, 40%, 98%)", "cardForeground": "hsl(210, 40%, 98%)",
"primary": "hsl(0, 100%, 53%)", "popover": "hsl(222.2, 84%, 4.9%)",
"primaryForeground": "hsl(222.2, 47.4%, 11.2%)", "popoverForeground": "hsl(210, 40%, 98%)",
"secondary": "hsl(217.2, 32.6%, 17.5%)", "primary": "hsl(0, 100%, 53%)",
"secondaryForeground": "hsl(210, 40%, 98%)", "primaryForeground": "hsl(222.2, 47.4%, 11.2%)",
"muted": "hsl(217.2, 32.6%, 17.5%)", "secondary": "hsl(217.2, 32.6%, 17.5%)",
"mutedForeground": "hsl(215, 20.2%, 65.1%)", "secondaryForeground": "hsl(210, 40%, 98%)",
"accent": "hsl(217.2, 32.6%, 17.5%)", "muted": "hsl(217.2, 32.6%, 17.5%)",
"accentForeground": "hsl(210, 40%, 98%)", "mutedForeground": "hsl(215, 20.2%, 65.1%)",
"destructive": "hsl(0, 62.8%, 30.6%)", "accent": "hsl(217.2, 32.6%, 17.5%)",
"destructiveForeground": "hsl(210, 40%, 98%)", "accentForeground": "hsl(210, 40%, 98%)",
"border": "hsl(217.2, 32.6%, 17.5%)", "destructive": "hsl(0, 62.8%, 30.6%)",
"input": "hsl(217.2, 32.6%, 17.5%)", "destructiveForeground": "hsl(210, 40%, 98%)",
"ring": "hsl(0, 100%, 53%)", "border": "hsl(217.2, 32.6%, 17.5%)",
"sidebar": "hsl(222.2, 84%, 4.9%)", "input": "hsl(217.2, 32.6%, 17.5%)",
"sidebarForeground": "hsl(210, 40%, 98%)", "ring": "hsl(0, 100%, 53%)",
"sidebarPrimary": "hsl(0, 100%, 53%)", "sidebar": "hsl(222.2, 84%, 4.9%)",
"sidebarPrimaryForeground": "hsl(0, 0%, 100%)", "sidebarForeground": "hsl(210, 40%, 98%)",
"sidebarAccent": "hsl(217.2, 32.6%, 17.5%)", "sidebarPrimary": "hsl(0, 100%, 53%)",
"sidebarAccentForeground": "hsl(210, 40%, 98%)", "sidebarPrimaryForeground": "hsl(0, 0%, 100%)",
"sidebarBorder": "hsl(217.2, 32.6%, 17.5%)", "sidebarAccent": "hsl(217.2, 32.6%, 17.5%)",
"sidebarRing": "hsl(0, 100%, 53%)" "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", "borderRadius": "0.5rem",
"typography": { "typography": {
@@ -42,11 +77,11 @@
}, },
"keyColors": { "keyColors": {
"primary": "#FF0000", "primary": "#FF0000",
"background": "#0a0a0f", "background": "#0a0a0f (dark) / #ffffff (light)",
"card": "#141414", "card": "#141414 (dark) / #ffffff (light)",
"sidebar": "#0a0a0f", "sidebar": "#0a0a0f (dark) / #ffffff (light)",
"border": "#1a1a24", "border": "#1a1a24 (dark) / #e2e4ea (light)",
"text": "#e8e8ef", "text": "#e8e8ef (dark) / #0a0a1a (light)",
"mutedText": "#888888" "mutedText": "#888888 (dark) / #6b7280 (light)"
} }
} }
+225 -74
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false) const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,9 +196,14 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
@@ -198,23 +215,35 @@ export default function CalendarPage() {
setFormSaving(true) setFormSaving(true)
try { try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +264,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +291,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -446,6 +477,11 @@ export default function CalendarPage() {
> >
<Icon className="h-2 w-2 shrink-0 opacity-50" /> <Icon className="h-2 w-2 shrink-0 opacity-50" />
<span className="truncate font-medium">{event.title}</span> <span className="truncate font-medium">{event.title}</span>
{event.lead && (
<span className="shrink-0 text-[9px] font-semibold text-muted-foreground/50 ml-auto">
{event.lead.contactName}
</span>
)}
</div> </div>
</button> </button>
) )
@@ -464,6 +500,15 @@ export default function CalendarPage() {
})} })}
</div> </div>
</div> </div>
{/* Unscheduled projects bar */}
{events.filter(e => !e.startTime && e.eventType === "website_creation").length > 0 && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border bg-gradient-to-r from-violet-500/5 to-transparent shrink-0">
<Globe className="h-3.5 w-3.5 text-violet-500/70" />
<span className="text-xs font-semibold text-violet-600 dark:text-violet-400">
{events.filter(e => !e.startTime && e.eventType === "website_creation").length} unscheduled project(s)
</span>
</div>
)}
</div> </div>
</div> </div>
@@ -534,12 +579,19 @@ export default function CalendarPage() {
{event.status} {event.status}
</span> </span>
</div> </div>
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium"> {event.startTime ? (
<Clock className="h-3 w-3" /> <div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/60 font-medium">
{formatDate(event.startTime)} <Clock className="h-3 w-3" />
<span className="text-muted-foreground/20">·</span> {formatDate(event.startTime)}
{formatTime(event.startTime)} <span className="text-muted-foreground/20">·</span>
</div> {formatTime(event.startTime)}
</div>
) : (
<div className="flex items-center gap-1.5 mt-1 text-[10px] text-muted-foreground/40 font-medium">
<Globe className="h-3 w-3" />
Website project no schedule
</div>
)}
{event.durationMinutes && ( {event.durationMinutes && (
<div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium"> <div className="flex items-center gap-1 mt-0.5 text-[10px] text-muted-foreground/40 font-medium">
<Zap className="h-2.5 w-2.5" /> <Zap className="h-2.5 w-2.5" />
@@ -547,21 +599,20 @@ export default function CalendarPage() {
</div> </div>
)} )}
<div className="flex items-center gap-1 mt-1"> <div className="flex items-center gap-1 mt-1">
<span className={cn( {event.developerId === user?.id ? (
"h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1", <>
event.userId === user?.id <span className="h-3.5 w-3.5 rounded-full text-[7px] flex items-center justify-center font-bold ring-1 bg-amber-500/10 text-amber-600 dark:text-amber-400 ring-amber-500/20">
? "bg-primary/10 text-primary ring-primary/20" {event.creator?.name.charAt(0) || "?"}
: "bg-muted text-muted-foreground/60 ring-border", </span>
)}> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id Assigned by {event.creator?.name || "Unknown"}
? (event.participant?.name.charAt(0) || "?") </span>
: (event.creator?.name.charAt(0) || "?")} </>
</span> ) : event.lead ? (
<span className="text-[10px] text-muted-foreground/60 font-medium"> <span className="text-[10px] text-muted-foreground/60 font-medium">
{event.userId === user?.id {event.lead.contactName}{event.lead.companyName ? `${event.lead.companyName}` : ""}
? (event.participant?.name || "No participant") </span>
: (event.creator?.name || "Unknown")} ) : null}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -579,7 +630,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +659,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</div> </div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +689,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -664,8 +729,40 @@ export default function CalendarPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" /> <Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Project Details</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Describe the project, requirements, and any relevant details..." rows={3} className="resize-none" />
</div> </div>
</div> </div>
<DialogFooter className="flex items-center justify-between border-t pt-4"> <DialogFooter className="flex items-center justify-between border-t pt-4">
@@ -690,7 +787,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +808,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
</div> ) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +855,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p> <p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </div>
)} )}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +921,32 @@ export default function CalendarPage() {
</div> </div>
</div> </div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */} {/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +981,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+6 -1
View File
@@ -1,10 +1,11 @@
export type EventType = "meeting" | "call" | "chat" | "follow_up" | "demo" | "other" export type EventType = "call" | "follow_up" | "website_creation"
export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled" export type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled"
export interface CalendarEvent { export interface CalendarEvent {
id: string id: string
userId: string userId: string
participantId: string | null participantId: string | null
developerId: string | null
leadId: string | null leadId: string | null
conversationId: string | null conversationId: string | null
title: string title: string
@@ -17,6 +18,10 @@ export interface CalendarEvent {
status: EventStatus status: EventStatus
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null
lead: { id: string; companyName: string; contactName: string } | null lead: { id: string; companyName: string; contactName: string } | null
clientName: string | null
clientEmail: string | null
clientPhone: string | null
createdAt: string createdAt: string
} }
+18 -3
View File
@@ -195,6 +195,9 @@ ON CONFLICT DO NOTHING;
-- admin_demo / AdminAccess@2026 -- admin_demo / AdminAccess@2026
-- sales_demo / SalesAccess@2026 -- sales_demo / SalesAccess@2026
-- dev_demo / DevTesting@2026 -- dev_demo / DevTesting@2026
-- ewan (password change required on first login)
-- caitlin (password change required on first login)
-- dillen (password change required on first login)
INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES INSERT INTO users (id, username, email, password_hash, first_name, last_name, is_active, password_change_required) VALUES
('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za', ('00000000-0000-0000-0000-000000000001', 'superadmin_demo', 'superadmin@coastit.co.za',
@@ -208,7 +211,16 @@ INSERT INTO users (id, username, email, password_hash, first_name, last_name, is
'Sales', 'User', TRUE, TRUE), 'Sales', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za', ('00000000-0000-0000-0000-000000000004', 'dev_demo', 'dev@coastit.co.za',
'$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa', '$2b$12$ghyJFb17lXoFOCYUPB6Fk.q8wDNOJhq9OUPNzd5DKaZsDjCF2NBJa',
'Dev', 'User', TRUE, TRUE) 'Dev', 'User', TRUE, TRUE),
('00000000-0000-0000-0000-000000000005', 'ewan', 'ewan@coastit.co.za',
'$2b$12$nO.9p.f4oWFhfScxM8MGQuiR9YjU85YTIqcb1kS.kyDBMHdmQ.EyG',
'Ewan', 'Scheepers', TRUE, TRUE),
('00000000-0000-0000-0000-000000000006', 'caitlin', 'caitlin@coastit.co.za',
'$2b$12$I5FHjje4OA5raP3642twT.Wmcl00rA1/wDPiQjRK14yDgybUjmrYG',
'Caitlin', 'Hermanus', TRUE, TRUE),
('00000000-0000-0000-0000-000000000007', 'dillen', 'dillen@coastit.co.za',
'$2b$12$QnvaRzdJEV/Bq8tp5vguM.ad.oeAcV2bjdzndIFdA4Opn5vY2WVaW',
'Dillen', 'van der Merwe', TRUE, TRUE)
ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING; ON CONFLICT (username) WHERE (deleted_at IS NULL) DO NOTHING;
-- Update the SUPER_ADMIN to set created_by to self (post-bootstrap) -- Update the SUPER_ADMIN to set created_by to self (post-bootstrap)
@@ -217,7 +229,7 @@ WHERE id = '00000000-0000-0000-0000-000000000001' AND created_by IS NULL;
-- Set created_by for other users (SUPER_ADMIN created them) -- Set created_by for other users (SUPER_ADMIN created them)
UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001' UPDATE users SET created_by = '00000000-0000-0000-0000-000000000001'
WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004') WHERE id IN ('00000000-0000-0000-0000-000000000002','00000000-0000-0000-0000-000000000003','00000000-0000-0000-0000-000000000004','00000000-0000-0000-0000-000000000005','00000000-0000-0000-0000-000000000006','00000000-0000-0000-0000-000000000007')
AND created_by IS NULL; AND created_by IS NULL;
-- ============================================================================ -- ============================================================================
@@ -233,7 +245,10 @@ ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES INSERT INTO user_roles (user_id, role_id, assigned_by) VALUES
('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000002', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'), ('00000000-0000-0000-0000-000000000003', '00000003-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001') ('00000000-0000-0000-0000-000000000004', '00000004-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000005', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000006', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001'),
('00000000-0000-0000-0000-000000000007', '00000002-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000001')
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- ============================================================================ -- ============================================================================
@@ -595,6 +595,11 @@ WHERE id = '00000000-0000-0000-0000-000000000003' AND password_encrypted IS NULL
UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026') UPDATE users SET password_encrypted = encrypt_password('DevTesting@2026')
WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL; WHERE id = '00000000-0000-0000-0000-000000000004' AND password_encrypted IS NULL;
-- NOTE: New admin users (ewan, caitlin, dillen) have password_encrypted=NULL.
-- They will set their own passwords on first login (password_change_required=TRUE).
-- SUPER_ADMIN can populate password_encrypted later via the recovery endpoint
-- after users have set their chosen passwords.
-- ============================================================================ -- ============================================================================
-- FUTURE REQUIREMENT NOTE: -- FUTURE REQUIREMENT NOTE:
-- If this system is ever exposed publicly, remove reversible password storage -- If this system is ever exposed publicly, remove reversible password storage
@@ -0,0 +1,4 @@
ALTER TABLE scheduled_events
ADD COLUMN developer_id UUID REFERENCES users(id) ON DELETE SET NULL;
CREATE INDEX idx_scheduled_events_developer ON scheduled_events(developer_id);
@@ -0,0 +1,8 @@
ALTER TABLE scheduled_events
DROP CONSTRAINT IF EXISTS chk_event_type,
ADD CONSTRAINT chk_event_type CHECK (event_type IN ('call', 'follow_up', 'website_creation'));
ALTER TABLE scheduled_events
ADD COLUMN IF NOT EXISTS client_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS client_phone VARCHAR(50);
@@ -0,0 +1,2 @@
ALTER TABLE scheduled_events
ALTER COLUMN start_time DROP NOT NULL;
+9
View File
@@ -66,5 +66,14 @@ BEGIN;
\echo '=== Running 016_participant_notes.sql (Participant Notes Column) ===' \echo '=== Running 016_participant_notes.sql (Participant Notes Column) ==='
\i 016_participant_notes.sql \i 016_participant_notes.sql
\echo '=== Running 017_calendar_developer.sql (Calendar Developer Assignment) ==='
\i 017_calendar_developer.sql
\echo '=== Running 018_website_creation_type.sql (Website Creation Event Type) ==='
\i 018_website_creation_type.sql
\echo '=== Running 019_allow_null_start_time.sql (Allow Null Start Time) ==='
\i 019_allow_null_start_time.sql
\echo '=== Migration Complete ===' \echo '=== Migration Complete ==='
COMMIT; COMMIT;
+7 -1
View File
@@ -6,13 +6,19 @@
"dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start", "dev": "npm run dev:precheck & npm run dev:ollama & npm run dev:start",
"dev:signaling": "node signaling-server.mjs", "dev:signaling": "node signaling-server.mjs",
"dev:open": "node scripts/open-browser.mjs", "dev:open": "node scripts/open-browser.mjs",
"dev:start": "concurrently -n AI,BROWSE,SIGNAL,NEXT,OPEN -c cyan,magenta,yellow,green,white \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"", "dev:repair": "node scripts/code-repair-agent.mjs --watch",
"dev:start": "concurrently -n REPAIR,AI,BROWSE,SIGNAL,NEXT,OPEN -c red,cyan,magenta,yellow,green,white \"npm run dev:repair\" \"npm run dev:rust\" \"npm run dev:browser-use\" \"npm run dev:signaling\" \"npm run dev:next\" \"npm run dev:open\"",
"dev:next": "next dev -p 3006", "dev:next": "next dev -p 3006",
"dev:precheck": "node scripts/precheck.mjs", "dev:precheck": "node scripts/precheck.mjs",
"dev:ollama": "node scripts/ensure-ollama.mjs", "dev:ollama": "node scripts/ensure-ollama.mjs",
"dev:rust": "node ai-server/index.mjs", "dev:rust": "node ai-server/index.mjs",
"dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py", "dev:browser-use": "cd browser-use-service && node ../scripts/run-python.mjs main.py",
"setup": "node scripts/setup.mjs", "setup": "node scripts/setup.mjs",
"setup:self-heal": "node scripts/setup.mjs --self-heal",
"repair": "node scripts/code-repair-agent.mjs",
"repair:watch": "node scripts/code-repair-agent.mjs --watch",
"repair:ci": "node scripts/code-repair-agent.mjs --ci",
"build:fix": "npm run build 2>&1 || node scripts/code-repair-agent.mjs --ci",
"build": "next build", "build": "next build",
"start": "next start -p 3006", "start": "next start -p 3006",
"lint": "eslint" "lint": "eslint"
+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 ────────────────────────────────────────────────── // ── Port Precheck ──────────────────────────────────────────────────
// Kills any existing processes on ports 3001, 3006, 3007, 3008. // Kills any existing processes on ports 3001, 3006, 3007, 3008.
// These are the AI server, Next.js frontend, Signaling server, and // These are the AI server, Next.js frontend, Signaling server, and
// Python scraper respectively. // Python scraper respectively.
// Runs before anything else starts to avoid EADDRINUSE errors. // Runs before anything else starts to avoid EADDRINUSE errors.
import { execSync } from "node:child_process"
import { platform } from "node:os"
const PORTS = [3001, 3006, 3007, 3008] const PORTS = [3001, 3006, 3007, 3008]
if (platform() === "win32") { if (platform() === "win32") {
+195 -16
View File
@@ -5,17 +5,22 @@
// 2. pip install -r requirements.txt (Python dependencies) // 2. pip install -r requirements.txt (Python dependencies)
// 3. playwright install firefox chromium (Playwright browsers) // 3. playwright install firefox chromium (Playwright browsers)
// 4. Copies .env.example to .env.local if not exists // 4. Copies .env.example to .env.local if not exists
// 5. --self-heal flag: runs build check + auto-generates missing modules
// //
// All steps are cross-platform (Windows, Mac, Linux). // All steps are cross-platform (Windows, Mac, Linux).
// Uses execSync for simplicity since each step blocks the next.
import { execSync } from "node:child_process" import { execSync } from "node:child_process"
import { existsSync, copyFileSync } from "node:fs" import { existsSync, copyFileSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"
import { platform } from "node:os" import { platform } from "node:os"
import { resolve, dirname } from "node:path"
import { fileURLToPath } from "node:url"
const SEP = platform() === "win32" ? "&" : ";" const SEP = platform() === "win32" ? "&" : ";"
const SELF = dirname(fileURLToPath(import.meta.url))
const ROOT = resolve(SELF, "..")
// ── Helpers ────────────────────────────────────────────────────────
// Auto-detect Python executable (python vs python3)
function detectPython() { function detectPython() {
const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"] const candidates = platform() === "win32" ? ["python", "python3"] : ["python3", "python"]
for (const cmd of candidates) { for (const cmd of candidates) {
@@ -28,7 +33,6 @@ function detectPython() {
process.exit(1) process.exit(1)
} }
// Auto-detect pip (pip vs pip3), fall back to python -m pip
function detectPip(python) { function detectPip(python) {
const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"] const candidates = platform() === "win32" ? ["pip", "pip3"] : ["pip3", "pip"]
for (const cmd of candidates) { for (const cmd of candidates) {
@@ -40,41 +44,216 @@ function detectPip(python) {
return `${python} -m pip` return `${python} -m pip`
} }
const PY = detectPython() function run(cmd, label, opts = {}) {
const PIP = detectPip(PY)
function run(cmd, label) {
console.log(`\n── ${label} ──`) console.log(`\n── ${label} ──`)
try { try {
execSync(cmd, { stdio: "inherit", timeout: 120000 }) execSync(cmd, { stdio: "inherit", timeout: opts.timeout || 120000, cwd: opts.cwd || ROOT })
} catch (e) { } catch (e) {
if (opts.optional) {
console.log(` ~ Skipped (${e.message})`)
return false
}
console.error(` ✗ Failed: ${e.message}`) console.error(` ✗ Failed: ${e.message}`)
process.exit(1) process.exit(1)
} }
return true
} }
// ── Self-Healing Module Registry ───────────────────────────────────
function loadRegistry() {
const registryPath = resolve(ROOT, ".setup-templates", "registry.json")
const templatesDir = resolve(ROOT, ".setup-templates", "templates")
const builtInTemplates = {
"data/stickers": { template: "stickers.ts" },
"data/constants": { template: "constants.ts" },
"lib/utils": { template: "utils.ts" },
"types/index": { template: "types-index.ts" },
"hooks/use-media": { template: "hooks/use-media.ts" },
"hooks/use-debounce": { template: "hooks/use-debounce.ts" },
"hooks/use-local-storage": { template: "hooks/use-local-storage.ts" },
}
const builtInFallbacks = {
"@/data/*": "data-generic.ts",
"@/lib/*": "lib-generic.ts",
"@/hooks/*": "hook-generic.ts",
"@/types/*": "types-generic.ts",
"@/components/*": "component-generic.tsx",
"@/utils/*": "utils-generic.ts",
}
let templates = { ...builtInTemplates }
let fallbacks = { ...builtInFallbacks }
try {
if (existsSync(registryPath)) {
const custom = JSON.parse(readFileSync(registryPath, "utf-8"))
templates = { ...templates, ...(custom.templates || {}) }
fallbacks = { ...fallbacks, ...(custom.fallbackTemplates || {}) }
}
} catch {}
const templateCache = {}
function getTemplate(templateName) {
if (templateCache[templateName]) return templateCache[templateName]
const candidatePaths = [
resolve(templatesDir, templateName),
resolve(templatesDir, "hooks", templateName.replace("hooks/", "")),
]
for (const p of candidatePaths) {
if (existsSync(p)) {
const content = readFileSync(p, "utf-8")
templateCache[templateName] = content
return content
}
}
return ""
}
function matchFallback(internalPath) {
for (const [pattern, templateFile] of Object.entries(fallbacks)) {
const regexStr = "^" + pattern
.replace(/\//g, "\\/").replace(/\./g, "\\.")
.replace(/\*/g, ".*")
.replace(/[$^+(){}[\]|]/g, "\\$&") + "$"
const regex = new RegExp(regexStr)
if (regex.test(internalPath)) return templateFile
}
return null
}
function generateModule(internalPath) {
let templateFile = templates[internalPath]?.template
if (!templateFile) templateFile = matchFallback(internalPath)
if (!templateFile) return { success: false, error: `No template for @/${internalPath}` }
const content = getTemplate(templateFile)
if (!content) return { success: false, error: `Template file not found: ${templateFile}` }
const ext = templateFile.endsWith(".tsx") ? ".tsx" : ".ts"
const filePath = resolve(ROOT, "src", internalPath + ext)
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, content, "utf-8")
return { success: true, filePath, message: `Generated @/${internalPath}` }
}
return { generateModule }
}
function parseMissingModules(buildOutput) {
const results = []
const seen = new Set()
// Pattern: error TS2307: Cannot find module '@/path' or its corresponding type declarations.
const errorRegex = /error\s+TS\d+:\s+Cannot find module\s+'([^']+)'/g
let match
while ((match = errorRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
// Also catch webpack-style: Module not found: Error: Can't resolve '@/path'
const webpackRegex = /Module not found: Error: Can't resolve\s+'([^']+)'/g
while ((match = webpackRegex.exec(buildOutput)) !== null) {
const importPath = match[1]
if (!seen.has(importPath)) {
seen.add(importPath)
const isInternal = importPath.startsWith("@/") || importPath.startsWith("~/")
const internalPath = isInternal ? importPath.replace(/^[@~]\//, "") : undefined
results.push({ importPath, isInternal, internalPath })
}
}
return results
}
// ── Main ───────────────────────────────────────────────────────────
const args = process.argv.slice(2)
const doSelfHeal = args.includes("--self-heal") || args.includes("-s")
const PY = detectPython()
const PIP = detectPip(PY)
console.log("=== CoastIT CRM Setup ===\n") console.log("=== CoastIT CRM Setup ===\n")
// 1. Node dependencies // 1. Node dependencies
run("npm install", "Installing Node.js dependencies") run("npm install", "Installing Node.js dependencies")
// 2. Python dependencies (run from browser-use-service directory) // 2. Python dependencies
run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies") run(`cd browser-use-service ${SEP} ${PIP} install -r requirements.txt`, "Installing Python dependencies")
// 3. Playwright browsers (Firefox for primary scraping, Chromium for Chrome/Edge/Opera + Agent fallback) // 3. Playwright browsers
run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers") run(`${PY} -m playwright install firefox chromium`, "Installing Playwright browsers")
// 4. .env file — create from template if it doesn't exist // 4. .env file
if (!existsSync(".env.local")) { if (!existsSync(resolve(ROOT, ".env.local"))) {
console.log("\n── Creating .env.local ──") console.log("\n── Creating .env.local ──")
copyFileSync(".env.example", ".env.local") copyFileSync(resolve(ROOT, ".env.example"), resolve(ROOT, ".env.local"))
console.log(" ✓ Created .env.local from .env.example — edit it with your settings") console.log(" ✓ Created .env.local from .env.example — edit it with your settings")
} else { } else {
console.log("\n── .env.local already exists, skipping ──") console.log("\n── .env.local already exists, skipping ──")
} }
// 5. Remaining manual steps // 5. Self-healing build check (--self-heal flag)
console.log("\n── Next steps ──") if (doSelfHeal) {
console.log("\n── Self-Healing Build Check ──")
const { generateModule } = loadRegistry()
let lastGeneratedCount = -1
let iteration = 0
const MAX_ITERATIONS = 5
while (iteration < MAX_ITERATIONS) {
iteration++
console.log(` Build check pass ${iteration}/${MAX_ITERATIONS}...`)
let buildOutput = ""
try {
buildOutput = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT }).toString()
} catch (e) {
buildOutput = e.stdout?.toString() || e.message || ""
}
const missing = parseMissingModules(buildOutput)
const internalMissing = missing
.filter(m => m.isInternal && m.internalPath)
.filter((m, i, arr) => arr.findIndex(x => x.internalPath === m.internalPath) === i) // dedup
if (internalMissing.length === 0) {
console.log(" ✓ No missing internal modules found!")
break
}
console.log(` Found ${internalMissing.length} missing internal module(s)`)
let generated = 0
for (const mod of internalMissing) {
const result = generateModule(mod.internalPath)
if (result.success) {
console.log(`${result.message}`)
generated++
} else {
console.log(`${result.error}`)
}
}
if (generated === 0 || generated === lastGeneratedCount) {
console.log(" No new modules could be generated. Stopping.")
break
}
lastGeneratedCount = generated
}
}
// 6. Final summary
console.log("\n── Final Steps ──")
console.log(" 1. Make sure PostgreSQL is running with database 'crm'") console.log(" 1. Make sure PostgreSQL is running with database 'crm'")
console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b") console.log(" 2. Pull the Ollama model: ollama pull dolphin-llama3:8b")
console.log(" 3. Edit .env.local with your settings") console.log(" 3. Edit .env.local with your settings")
+111 -59
View File
@@ -1,82 +1,134 @@
"use client" "use client"
import { useState, useCallback } from "react" import { useState, useCallback, useRef, useEffect } from "react"
import { AIChat } from "@/components/ai/ai-chat" import { AIChat } from "@/components/ai/ai-chat"
import { JobSelector } from "@/components/ai/job-selector" import { JobSelector } from "@/components/ai/job-selector"
import { Bot, Lightbulb, Target, MessageSquare } from "lucide-react" import { Bot, ChevronRight } from "lucide-react"
const tips = [
"Ask for cold email templates for a specific job",
"Request objection handling tips",
"Ask for outreach strategies per industry",
"Generate a follow up sequence",
"Get LinkedIn connection message templates",
]
export default function AIAssistantPage() { export default function AIAssistantPage() {
const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null) const [selectedJob, setSelectedJob] = useState<{ job_title: string; keywords: string[]; industry: string; description: string } | null>(null)
const [recentPrompts, setRecentPrompts] = useState<string[]>([])
const aiChatRef = useRef<{ fillInput: (text: string) => void } | null>(null)
const handleJobSelect = useCallback((job: typeof selectedJob) => { const handleJobSelect = useCallback((job: typeof selectedJob) => {
setSelectedJob(job) setSelectedJob(job)
}, []) }, [])
const handleTipClick = useCallback((tip: string) => {
aiChatRef.current?.fillInput(tip)
}, [])
const handleRecentPromptClick = useCallback((prompt: string) => {
aiChatRef.current?.fillInput(prompt)
}, [])
const handleMessageSent = useCallback((msg: string) => {
setRecentPrompts((prev) => {
const next = [msg, ...prev.filter((p) => p !== msg)].slice(0, 3)
return next
})
}, [])
return ( return (
<div className="flex h-[calc(100vh-3.5rem)]"> <div className="flex h-[calc(100vh-3.5rem)] bg-[#0f1117]">
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<div className="border-b border-[#2a2a35] px-6 py-4"> <div className="bg-[#1a1d2e]/80 backdrop-blur-md border-b border-[#ffffff08] px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between">
<div className="h-9 w-9 rounded-lg bg-[#1BB0CE]/15 flex items-center justify-center"> <div className="flex items-center gap-4">
<Bot className="h-5 w-5 text-[#1BB0CE]" /> <div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-white font-bold text-lg">AI Sales Assistant</h1>
<p className="text-[#6b7280] text-xs mt-0.5">Powered by local AI</p>
</div>
</div> </div>
<div> <div className="flex items-center gap-4">
<h1 className="text-lg font-semibold text-[#e8e8ef]">AI Sales Assistant</h1> <div className="flex items-center gap-2">
<p className="text-xs text-[#6a6a75]">Uncensored sales tips and strategies powered by local AI</p> <span className="w-2 h-2 rounded-full bg-[#22c55e] animate-pulse" />
<span className="text-[#22c55e] text-xs font-medium">Online</span>
</div>
<div className="w-px h-5 bg-[#ffffff08]" />
<div className="bg-[#ffffff08] rounded-lg px-3 py-1.5">
<span className="text-[#9ca3af] text-xs">Local AI Model</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<AIChat ref={aiChatRef} onMessageSent={handleMessageSent} />
<div className="flex-1 flex"> </div>
<div className="flex-1 flex flex-col min-w-0 border-r border-[#2a2a35]"> <div className="w-[300px] flex-none bg-[#13151f] border-l border-[#ffffff08] p-5 overflow-y-auto h-full flex flex-col">
<AIChat /> <div>
<div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Target Job</span>
</div> </div>
<JobSelector onSelect={handleJobSelect} />
<div className="w-72 flex-none p-4 space-y-4 overflow-y-auto"> {selectedJob && (
<div> <div className="bg-[#1a1d2e]/50 border border-[#ffffff08] rounded-xl p-3.5 mt-3 space-y-2">
<h3 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider mb-2 flex items-center gap-1.5"> <h4 className="text-sm font-semibold text-[#e5e7eb]">{selectedJob.job_title}</h4>
<Target className="h-3.5 w-3.5" /> <span className="text-xs px-2 py-0.5 rounded-md bg-[#f97316]/10 text-[#f97316] font-medium inline-block">{selectedJob.industry}</span>
Target Job <p className="text-xs text-[#6b7280] leading-relaxed">{selectedJob.description}</p>
</h3> <div className="flex flex-wrap gap-1.5">
<JobSelector onSelect={handleJobSelect} /> {selectedJob.keywords.map((kw, i) => (
</div> <span key={i} className="text-xs px-2 py-0.5 rounded-md bg-[#ffffff08] text-[#4b5563]">{kw}</span>
))}
{selectedJob && (
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2">
<h4 className="text-sm font-medium text-[#e8e8ef]">{selectedJob.job_title}</h4>
<div className="flex items-center gap-1.5">
<span className="text-xs px-1.5 py-0.5 rounded bg-[#1BB0CE]/10 text-[#1BB0CE]">{selectedJob.industry}</span>
</div>
<p className="text-xs text-[#8a8a95]">{selectedJob.description}</p>
<div className="flex flex-wrap gap-1">
{selectedJob.keywords.map((kw, i) => (
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[#2a2a35] text-[#6a6a75]">
{kw}
</span>
))}
</div>
</div> </div>
)} </div>
)}
<div className="bg-[#1a1a24] border border-[#2a2a35] rounded-lg p-3 space-y-2"> </div>
<h4 className="text-xs font-semibold text-[#6a6a75] uppercase tracking-wider flex items-center gap-1.5"> <div className="mt-6">
<Lightbulb className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 mb-3">
Tips <span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
</h4> <span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Quick Tips</span>
<ul className="space-y-1.5 text-xs text-[#8a8a95]"> </div>
<li className="flex gap-2"> {tips.map((tip, i) => (
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" /> <div
Ask for cold email templates for a specific job key={i}
</li> onClick={() => handleTipClick(tip)}
<li className="flex gap-2"> className="flex items-start gap-2.5 bg-[#1a1d2e]/50 hover:bg-[#1a1d2e] border border-[#ffffff08] hover:border-[#f97316]/20 rounded-xl p-3.5 mb-2 cursor-pointer transition-all duration-200 group"
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" /> >
Request objection handling tips <ChevronRight className="h-3.5 w-3.5 mt-0.5 text-[#374151] group-hover:text-[#f97316] transition-colors duration-200 flex-shrink-0" />
</li> <span className="text-[#9ca3af] text-xs leading-5 group-hover:text-[#e5e7eb] transition-colors duration-200">{tip}</span>
<li className="flex gap-2"> </div>
<MessageSquare className="h-3 w-3 mt-0.5 flex-none text-[#1BB0CE]" /> ))}
Ask for outreach strategies per industry </div>
</li> <div className="mt-6">
</ul> <div className="flex items-center gap-2 mb-3">
<span className="w-1.5 h-1.5 rounded-full bg-[#f97316]" />
<span className="text-[#f97316] text-[10px] font-bold uppercase tracking-[0.15em]">Recent Prompts</span>
</div>
{recentPrompts.length > 0 ? (
recentPrompts.map((prompt, i) => (
<div
key={i}
onClick={() => handleRecentPromptClick(prompt)}
className="bg-[#1a1d2e]/50 rounded-xl p-3 mb-2 border border-[#ffffff08] text-[#6b7280] text-xs truncate hover:text-[#9ca3af] cursor-pointer transition-colors duration-200"
>
{prompt}
</div>
))
) : (
<div className="text-[#374151] text-xs text-center py-4">Your recent prompts will appear here</div>
)}
</div>
<div className="mt-auto border-t border-[#ffffff08] pt-4">
<div className="flex gap-4">
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Messages today</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">24</div>
</div>
<div className="flex-1">
<div className="text-[#4b5563] text-[10px] uppercase tracking-wide">Tokens used</div>
<div className="text-[#e5e7eb] text-sm font-semibold mt-0.5">12.4k</div>
</div> </div>
</div> </div>
</div> </div>
+189 -54
View File
@@ -21,17 +21,14 @@ import { useNotifications } from "@/providers/notification-provider"
import { toast } from "sonner" import { toast } from "sonner"
import { import {
ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone, ChevronLeft, ChevronRight, Plus, Calendar, Clock, Phone,
Video, Users, CheckCircle2, XCircle, RotateCcw, Loader2, Users, CheckCircle2, XCircle, RotateCcw, Loader2,
ArrowUpRight, Zap, StickyNote, ArrowUpRight, Zap, StickyNote, Globe,
} from "lucide-react" } from "lucide-react"
const EVENT_ICONS: Record<EventType, React.ElementType> = { const EVENT_ICONS: Record<EventType, React.ElementType> = {
meeting: Users,
call: Phone, call: Phone,
chat: Video,
follow_up: Clock, follow_up: Clock,
demo: Calendar, website_creation: Globe,
other: Calendar,
} }
function eventVars(type: EventType) { function eventVars(type: EventType) {
@@ -85,14 +82,20 @@ export default function CalendarPage() {
const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null) const [editingEvent, setEditingEvent] = useState<CalendarEvent | null>(null)
const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null) const [detailEvent, setDetailEvent] = useState<CalendarEvent | null>(null)
const [users, setUsers] = useState<{ id: string; name: string; email: string }[]>([]) const [users, setUsers] = useState<{ id: string; name: string; email: string; role: string }[]>([])
const [leads, setLeads] = useState<{ id: string; contactName: string; companyName: string }[]>([])
const [formTitle, setFormTitle] = useState("") const [formTitle, setFormTitle] = useState("")
const [formDescription, setFormDescription] = useState("") const [formDescription, setFormDescription] = useState("")
const [formType, setFormType] = useState<EventType>("meeting") const [formType, setFormType] = useState<EventType>("website_creation")
const [formDate, setFormDate] = useState("") const [formDate, setFormDate] = useState("")
const [formTime, setFormTime] = useState("") const [formTime, setFormTime] = useState("")
const [formDuration, setFormDuration] = useState("30") const [formDuration, setFormDuration] = useState("30")
const [formParticipantId, setFormParticipantId] = useState("") const [formParticipantId, setFormParticipantId] = useState("")
const [formDeveloperId, setFormDeveloperId] = useState("")
const [formLeadId, setFormLeadId] = useState("")
const [formClientName, setFormClientName] = useState("")
const [formClientEmail, setFormClientEmail] = useState("")
const [formClientPhone, setFormClientPhone] = useState("")
const [formSaving, setFormSaving] = useState(false) const [formSaving, setFormSaving] = useState(false)
const [editNotes, setEditNotes] = useState(false) const [editNotes, setEditNotes] = useState(false)
const [notesText, setNotesText] = useState("") const [notesText, setNotesText] = useState("")
@@ -124,6 +127,10 @@ export default function CalendarPage() {
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setUsers(data.users || [])) .then((data) => setUsers(data.users || []))
.catch(() => {}) .catch(() => {})
fetch("/api/leads?limit=200")
.then((r) => r.json())
.then((data) => setLeads((Array.isArray(data) ? data : data?.leads || []).map((l: any) => ({ id: l.id, contactName: l.contactName, companyName: l.companyName }))))
.catch(() => {})
}, [user, router]) }, [user, router])
useEffect(() => { useEffect(() => {
@@ -158,7 +165,7 @@ export default function CalendarPage() {
const getEventsForDay = useCallback((day: number) => { const getEventsForDay = useCallback((day: number) => {
const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}` const dateStr = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`
return events.filter((e) => e.startTime.startsWith(dateStr)) return events.filter((e) => e.startTime && e.startTime.startsWith(dateStr))
}, [events, currentYear, currentMonth]) }, [events, currentYear, currentMonth])
const isToday = (day: number) => { const isToday = (day: number) => {
@@ -169,9 +176,14 @@ export default function CalendarPage() {
setEditingEvent(null) setEditingEvent(null)
setFormTitle("") setFormTitle("")
setFormDescription("") setFormDescription("")
setFormType("meeting") setFormType("website_creation")
setFormDuration("30") setFormDuration("30")
setFormParticipantId("") setFormParticipantId("")
setFormDeveloperId("")
setFormLeadId("")
setFormClientName("")
setFormClientEmail("")
setFormClientPhone("")
const d = day ? new Date(currentYear, currentMonth, day) : new Date() const d = day ? new Date(currentYear, currentMonth, day) : new Date()
setFormDate(d.toISOString().split("T")[0]) setFormDate(d.toISOString().split("T")[0])
setFormTime("10:00") setFormTime("10:00")
@@ -184,37 +196,58 @@ export default function CalendarPage() {
setFormDescription(event.description || "") setFormDescription(event.description || "")
setFormType(event.eventType) setFormType(event.eventType)
setFormDuration(String(event.durationMinutes || 30)) setFormDuration(String(event.durationMinutes || 30))
setFormDate(new Date(event.startTime).toISOString().split("T")[0]) setFormDate(event.startTime ? new Date(event.startTime).toISOString().split("T")[0] : "")
setFormTime(new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })) setFormTime(event.startTime ? new Date(event.startTime).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }) : "10:00")
setFormParticipantId(event.participantId || "") setFormParticipantId(event.participantId || "")
setFormDeveloperId(event.developerId || "")
setFormLeadId(event.leadId || "")
setFormClientName(event.clientName || "")
setFormClientEmail(event.clientEmail || "")
setFormClientPhone(event.clientPhone || "")
setDialogOpen(true) setDialogOpen(true)
} }
const handleSave = async () => { const handleSave = async () => {
if (!formTitle.trim() || !formDate || !formTime) { if (!formTitle.trim()) {
toast.error("Title, date, and time are required") toast.error("Title is required")
return
}
if (formType !== "website_creation" && (!formDate || !formTime)) {
toast.error("Date and time are required for this event type")
return return
} }
setFormSaving(true) setFormSaving(true)
try { try {
const startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
title: formTitle.trim(), title: formTitle.trim(),
description: formDescription.trim() || null, description: formDescription.trim() || null,
eventType: formType, eventType: formType,
startTime,
endTime,
durationMinutes: formDuration ? parseInt(formDuration) : null,
} }
let startTime: string | undefined
if (formType !== "website_creation") {
startTime = new Date(`${formDate}T${formTime}:00`).toISOString()
let endTime = null
if (formDuration) {
const end = new Date(startTime)
end.setMinutes(end.getMinutes() + parseInt(formDuration))
endTime = end.toISOString()
}
body.startTime = startTime
body.endTime = endTime
body.durationMinutes = formDuration ? parseInt(formDuration) : null
} else if (formDate) {
startTime = new Date(`${formDate}T00:00:00`).toISOString()
body.startTime = startTime
}
if (formClientName) body.clientName = formClientName
if (formClientEmail) body.clientEmail = formClientEmail
if (formClientPhone) body.clientPhone = formClientPhone
if (formParticipantId) body.participantId = formParticipantId if (formParticipantId) body.participantId = formParticipantId
if (formDeveloperId) body.developerId = formDeveloperId
if (formLeadId) body.leadId = formLeadId
let res let res
if (editingEvent) { if (editingEvent) {
@@ -235,11 +268,13 @@ export default function CalendarPage() {
const data = await res.json() const data = await res.json()
if (editingEvent) { if (editingEvent) {
setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === editingEvent.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success("Event updated") toast.success("Event updated")
} else { } else {
setEvents((prev) => [...prev, data.event]) setEvents((prev) => [...prev, data.event])
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar") if (startTime) {
addNotification("event_scheduled", `Event created: ${formTitle}`, `Scheduled for ${formatDate(startTime)} at ${formatTime(startTime)}`, "/calendar")
}
toast.success("Event created") toast.success("Event created")
} }
@@ -260,7 +295,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to update") if (!res.ok) throw new Error("Failed to update")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
toast.success(`Event ${newStatus}`) toast.success(`Event ${newStatus}`)
setDetailEvent(null) setDetailEvent(null)
} catch { } catch {
@@ -279,7 +314,7 @@ export default function CalendarPage() {
}) })
if (!res.ok) throw new Error("Failed to save") if (!res.ok) throw new Error("Failed to save")
const data = await res.json() const data = await res.json()
setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, lead: e.lead } : e)) setEvents((prev) => prev.map((e) => e.id === event.id ? { ...data.event, participant: e.participant, developer: e.developer, lead: e.lead } : e))
if (detailEvent && detailEvent.id === event.id) { if (detailEvent && detailEvent.id === event.id) {
setDetailEvent({ ...detailEvent, participantNotes: notesText || null }) setDetailEvent({ ...detailEvent, participantNotes: notesText || null })
} }
@@ -579,7 +614,7 @@ export default function CalendarPage() {
{/* Create/Edit Dialog */} {/* Create/Edit Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-[520px]"> <DialogContent className="sm:max-w-[520px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={cn( <div className={cn(
@@ -608,11 +643,28 @@ export default function CalendarPage() {
<Label htmlFor="date" className="text-xs font-semibold">Date</Label> <Label htmlFor="date" className="text-xs font-semibold">Date</Label>
<Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" /> <Input id="date" type="date" value={formDate} onChange={(e) => setFormDate(e.target.value)} className="h-10" />
</div> </div>
<div className="space-y-2"> <div className={cn("space-y-2", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<Label htmlFor="time" className="text-xs font-semibold">Time</Label> <Label htmlFor="time" className="text-xs font-semibold">Time</Label>
<Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" /> <Input id="time" type="time" value={formTime} onChange={(e) => setFormTime(e.target.value)} className="h-10" />
</div> </div>
</div> </div>
{formType === "website_creation" && (
<div className="space-y-4 rounded-xl border bg-muted/20 p-4">
<p className="text-xs font-semibold text-muted-foreground/70 uppercase tracking-wider">Client Details</p>
<div className="space-y-2">
<Label htmlFor="clientName" className="text-xs font-semibold">Name</Label>
<Input id="clientName" value={formClientName} onChange={(e) => setFormClientName(e.target.value)} placeholder="Client full name" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientEmail" className="text-xs font-semibold">Email</Label>
<Input id="clientEmail" type="email" value={formClientEmail} onChange={(e) => setFormClientEmail(e.target.value)} placeholder="client@example.com" className="h-10" />
</div>
<div className="space-y-2">
<Label htmlFor="clientPhone" className="text-xs font-semibold">Phone</Label>
<Input id="clientPhone" type="tel" value={formClientPhone} onChange={(e) => setFormClientPhone(e.target.value)} placeholder="+1 555-123-4567" className="h-10" />
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-xs font-semibold">Type</Label> <Label htmlFor="type" className="text-xs font-semibold">Type</Label>
@@ -621,19 +673,16 @@ export default function CalendarPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="meeting">Meeting</SelectItem> <SelectItem value="website_creation">Website Creation</SelectItem>
<SelectItem value="call">Call</SelectItem> <SelectItem value="call">Call</SelectItem>
<SelectItem value="chat">Video Chat</SelectItem>
<SelectItem value="follow_up">Follow Up</SelectItem> <SelectItem value="follow_up">Follow Up</SelectItem>
<SelectItem value="demo">Demo</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="duration" className="text-xs font-semibold">Duration</Label> <Label htmlFor="duration" className="text-xs font-semibold">Duration</Label>
<Select value={formDuration} onValueChange={setFormDuration}> <Select value={formDuration} onValueChange={setFormDuration}>
<SelectTrigger id="duration" className="h-10"> <SelectTrigger id="duration" className={cn("h-10", formType === "website_creation" && "opacity-40 pointer-events-none")}>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -663,6 +712,38 @@ export default function CalendarPage() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2">
<Label htmlFor="developer" className="text-xs font-semibold">Assign Developer</Label>
<Select value={formDeveloperId || "__none__"} onValueChange={(v) => setFormDeveloperId(v === "__none__" ? "" : v)}>
<SelectTrigger id="developer" className="h-10">
<SelectValue placeholder="Select a developer…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{users.map((u) => (
<SelectItem key={u.id} value={u.id}>
{u.name} {u.role ? `(${u.role})` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="lead" className="text-xs font-semibold">Client (Lead)</Label>
<Select value={formLeadId || "__none__"} onValueChange={(v) => setFormLeadId(v === "__none__" ? "" : v)}>
<SelectTrigger id="lead" className="h-10">
<SelectValue placeholder="Select a client lead…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">None</SelectItem>
{leads.map((l) => (
<SelectItem key={l.id} value={l.id}>
{l.contactName}{l.companyName ? `${l.companyName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-xs font-semibold">Description</Label> <Label htmlFor="description" className="text-xs font-semibold">Description</Label>
<Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" /> <Textarea id="description" value={formDescription} onChange={(e) => setFormDescription(e.target.value)} placeholder="Add any notes or agenda items..." rows={3} className="resize-none" />
@@ -690,7 +771,7 @@ export default function CalendarPage() {
{/* Event Detail Dialog */} {/* Event Detail Dialog */}
<Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}> <Dialog open={!!detailEvent} onOpenChange={(open) => { if (!open) setDetailEvent(null) }}>
{detailEvent && ( {detailEvent && (
<DialogContent className="sm:max-w-[460px]"> <DialogContent className="sm:max-w-[460px] max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}> <div className="p-3 rounded-xl border shadow-sm" style={eventBgStyle(detailEvent.eventType)}>
@@ -711,22 +792,29 @@ export default function CalendarPage() {
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Date / Time */} {/* Date / Time */}
<div className="grid grid-cols-2 gap-3"> {detailEvent.startTime ? (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="grid grid-cols-2 gap-3">
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<Calendar className="h-3.5 w-3.5" /> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
Date <Calendar className="h-3.5 w-3.5" />
Date
</div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatDate(detailEvent.startTime)}</p> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
</div> <div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <Clock className="h-3.5 w-3.5" />
<div className="flex items-center gap-2 text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5"> Time
<Clock className="h-3.5 w-3.5" /> </div>
Time <p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
<p className="text-sm font-bold">{formatTime(detailEvent.startTime)}</p>
</div> </div>
</div> ) : (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Schedule</p>
<p className="text-sm text-muted-foreground/50 italic">No scheduled time website creation project</p>
</div>
)}
{/* Duration + Type */} {/* Duration + Type */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@@ -751,11 +839,26 @@ export default function CalendarPage() {
{/* Description */} {/* Description */}
{detailEvent.description && ( {detailEvent.description && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Description</p> <p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5">Project Details</p>
<p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p> <p className="text-sm whitespace-pre-wrap leading-relaxed">{detailEvent.description}</p>
</div> </div>
)} )}
{/* Client details */}
{(detailEvent.clientName || detailEvent.clientEmail || detailEvent.clientPhone) && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-violet-500/5 to-violet-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
Client Details
</p>
<div className="space-y-1">
{detailEvent.clientName && <p className="text-sm font-bold">{detailEvent.clientName}</p>}
{detailEvent.clientEmail && <p className="text-sm text-muted-foreground/70">{detailEvent.clientEmail}</p>}
{detailEvent.clientPhone && <p className="text-sm text-muted-foreground/70">{detailEvent.clientPhone}</p>}
</div>
</div>
)}
{/* Linked lead */} {/* Linked lead */}
{detailEvent.lead && ( {detailEvent.lead && (
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
@@ -802,6 +905,32 @@ export default function CalendarPage() {
</div> </div>
</div> </div>
{/* Developer */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-amber-500/5 to-amber-500/[0.02] border shadow-sm">
<p className="text-[10px] font-semibold text-muted-foreground/50 uppercase tracking-wider mb-1.5 flex items-center gap-1.5">
<Users className="h-3 w-3" />
{detailEvent.developerId === user?.id ? "You (Developer)" : "Developer"}
</p>
{detailEvent.developer ? (
<p className="text-sm font-bold flex items-center gap-2">
<span className="h-6 w-6 rounded-full bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[10px] flex items-center justify-center font-bold ring-1 ring-amber-500/20">
{detailEvent.developer.name.charAt(0).toUpperCase()}
</span>
<span className="truncate">{detailEvent.developer.name}</span>
{detailEvent.developer.role && (
<span className="text-[10px] font-medium text-muted-foreground/50 capitalize shrink-0">({detailEvent.developer.role})</span>
)}
</p>
) : (
<p className="text-sm text-muted-foreground/50 italic">No developer assigned</p>
)}
{detailEvent.developerId === user?.id && detailEvent.userId !== user?.id && (
<p className="text-[11px] text-muted-foreground/60 mt-1.5 font-medium">
Assigned by {detailEvent.creator?.name || "Unknown"}
</p>
)}
</div>
{/* Participant Notes (editable by both parties) */} {/* Participant Notes (editable by both parties) */}
<div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm"> <div className="p-3.5 rounded-xl bg-gradient-to-br from-muted/50 to-muted/20 border shadow-sm">
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@@ -836,9 +965,15 @@ export default function CalendarPage() {
<DialogFooter className="flex items-center gap-2 border-t pt-4"> <DialogFooter className="flex items-center gap-2 border-t pt-4">
{detailEvent.status === "scheduled" && ( {detailEvent.status === "scheduled" && (
<> <>
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm"> {detailEvent.developerId === user?.id ? (
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete <Button variant="default" size="default" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm bg-emerald-600 hover:bg-emerald-700">
</Button> <CheckCircle2 className="h-4 w-4 mr-1.5" /> Mark Done
</Button>
) : (
<Button variant="default" size="sm" onClick={() => updateEventStatus(detailEvent, "completed")} className="shadow-sm">
<CheckCircle2 className="h-4 w-4 mr-1.5" /> Complete
</Button>
)}
<Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}> <Button variant="outline" size="sm" onClick={() => updateEventStatus(detailEvent, "cancelled")}>
<XCircle className="h-4 w-4 mr-1.5" /> Cancel <XCircle className="h-4 w-4 mr-1.5" /> Cancel
</Button> </Button>
+2 -5
View File
@@ -15,7 +15,7 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { import {
Search, Send, Phone, Video, MoreHorizontal, Paperclip, Search, Send, Phone, MoreHorizontal, Paperclip,
Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck, Smile, Flag, Ban, Trash2, Image, FileIcon, X, Mic, Square, Play, Pause, Check, CheckCheck,
CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail, CornerDownRight, Forward, Pencil, Download, Undo2, CalendarDays, Loader2, FolderOpen, Mail,
} from "lucide-react" } from "lucide-react"
@@ -792,7 +792,7 @@ const formatPreviewContent = (content: string) => {
}) })
return ( return (
<div className="flex h-[calc(100vh-8rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden"> <div className="flex h-[calc(100dvh-4rem)] -m-4 lg:-m-6 rounded-lg border bg-card overflow-hidden">
{/* Conversations list - left panel */} {/* Conversations list - left panel */}
<div <div
className="flex flex-col border-r shrink-0 overflow-hidden" className="flex flex-col border-r shrink-0 overflow-hidden"
@@ -923,9 +923,6 @@ const formatPreviewContent = (content: string) => {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsCallModalOpen(true)}>
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => toast.info("Video calling coming soon")}>
<Video className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setScheduleDialogOpen(true)}>
<CalendarDays className="h-4 w-4" /> <CalendarDays className="h-4 w-4" />
</Button> </Button>
+11 -8
View File
@@ -24,8 +24,10 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { DashboardStats } from "@/types" import { DashboardStats } from "@/types"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
export default function DashboardPage() { export default function DashboardPage() {
const { websiteTheme } = useWebsiteTheme()
const [period, setPeriod] = useState("6months") const [period, setPeriod] = useState("6months")
const [stats, setStats] = useState<DashboardStats | null>(null) const [stats, setStats] = useState<DashboardStats | null>(null)
const pollingRef = useRef<NodeJS.Timeout | null>(null) const pollingRef = useRef<NodeJS.Timeout | null>(null)
@@ -84,7 +86,7 @@ export default function DashboardPage() {
</PageHeader> </PageHeader>
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Pipeline Overview</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Pipeline Overview</p>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
{stats {stats
@@ -95,7 +97,7 @@ export default function DashboardPage() {
} }
</div> </div>
<p className="text-xs font-semibold tracking-widest uppercase text-[#888888] dark:text-[#666666]">Analytics</p> <p className="text-xs font-semibold tracking-widest uppercase text-[#8A9078] dark:text-[#666666]">Analytics</p>
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<LeadStatusChart data={stats?.statusDistribution ?? []} /> <LeadStatusChart data={stats?.statusDistribution ?? []} />
@@ -104,12 +106,13 @@ export default function DashboardPage() {
<RecentLeadsTable leads={stats?.recentLeads ?? []} /> <RecentLeadsTable leads={stats?.recentLeads ?? []} />
</div> </div>
{/* Daily Bugle watermark */} {websiteTheme === "spidey" && (
<div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]"> <div className="absolute top-12 right-4 pointer-events-none select-none z-0 opacity-[0.09] dark:opacity-[0.15]">
<span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider"> <span className="text-[96px] font-['Bangers',cursive] leading-none text-[#CC0000] dark:text-[#FF1111] tracking-wider">
DAILY BUGLE DAILY BUGLE
</span> </span>
</div> </div>
)}
</div> </div>
) )
} }
+10 -3
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { AppShell } from "@/components/layout/app-shell" import { AppShell } from "@/components/layout/app-shell"
import { UserProvider, useUser } from "@/providers/user-provider" import { UserProvider, useUser } from "@/providers/user-provider"
import { NotificationProvider } from "@/providers/notification-provider" import { NotificationProvider } from "@/providers/notification-provider"
@@ -8,8 +10,15 @@ import { Loader2 } from "lucide-react"
function DashboardContent({ children }: { children: React.ReactNode }) { function DashboardContent({ children }: { children: React.ReactNode }) {
const { user, loading } = useUser() const { user, loading } = useUser()
const router = useRouter()
if (loading) { useEffect(() => {
if (!user && !loading) {
router.push("/login")
}
}, [user, loading, router])
if (loading || !user) {
return ( return (
<div className="flex h-screen items-center justify-center"> <div className="flex h-screen items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
@@ -17,8 +26,6 @@ function DashboardContent({ children }: { children: React.ReactNode }) {
) )
} }
if (!user) return null
return <AppShell>{children}</AppShell> return <AppShell>{children}</AppShell>
} }
+32 -16
View File
@@ -1,4 +1,4 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest } from "next/server"
import { import {
comparePassword, comparePassword,
getUserByEmail, getUserByEmail,
@@ -10,8 +10,16 @@ import {
isAccountLocked, isAccountLocked,
createSession, createSession,
setSessionContext, setSessionContext,
SESSION_COOKIE,
} from "@/lib/auth" } from "@/lib/auth"
function jsonResponse(data: unknown, status: number) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
})
}
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const { email, username, password } = await request.json() const { email, username, password } = await request.json()
@@ -19,16 +27,16 @@ export async function POST(request: NextRequest) {
const credential = email || username const credential = email || username
if (!credential || !password) { if (!credential || !password) {
return NextResponse.json( return jsonResponse(
{ error: "Email/Username and password are required." }, { error: "Email/Username and password are required." },
{ status: 400 } 400
) )
} }
if (credential.trim().length === 0 || password.trim().length === 0) { if (credential.trim().length === 0 || password.trim().length === 0) {
return NextResponse.json( return jsonResponse(
{ error: "Credentials cannot be empty." }, { error: "Credentials cannot be empty." },
{ status: 400 } 400
) )
} }
@@ -58,9 +66,9 @@ export async function POST(request: NextRequest) {
false, false,
"User not found" "User not found"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
@@ -74,9 +82,9 @@ export async function POST(request: NextRequest) {
false, false,
lockStatus.reason lockStatus.reason
) )
return NextResponse.json( return jsonResponse(
{ error: lockStatus.reason }, { error: lockStatus.reason },
{ status: 423 } 423
) )
} }
@@ -91,9 +99,9 @@ export async function POST(request: NextRequest) {
false, false,
"Invalid password" "Invalid password"
) )
return NextResponse.json( return jsonResponse(
{ error: "Invalid email/username or password." }, { error: "Invalid email/username or password." },
{ status: 401 } 401
) )
} }
@@ -106,17 +114,25 @@ export async function POST(request: NextRequest) {
true true
) )
await createSession(dbUser.id, dbUser.role_name) const token = await createSession(dbUser.id, dbUser.role_name)
await setSessionContext(dbUser.id, ipAddress) await setSessionContext(dbUser.id, ipAddress)
const user = mapDbUserToSessionUser(dbUser) const user = mapDbUserToSessionUser(dbUser)
return NextResponse.json({ user }, { status: 200 }) const cookieStr = `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=86400${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
return new Response(JSON.stringify({ user }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": cookieStr,
},
})
} catch (error) { } catch (error) {
console.error("Login error:", error) console.error("Login error:", error)
return NextResponse.json( return new Response(
{ error: "Authentication service unavailable." }, JSON.stringify({ error: "Authentication service unavailable." }),
{ status: 503 } { status: 503, headers: { "Content-Type": "application/json" } }
) )
} }
} }
+11 -7
View File
@@ -1,15 +1,19 @@
import { NextResponse } from "next/server" import { SESSION_COOKIE } from "@/lib/auth"
import { destroySession } from "@/lib/auth"
export async function POST() { export async function POST() {
try { try {
await destroySession() return new Response(JSON.stringify({ success: true }), {
return NextResponse.json({ success: true }, { status: 200 }) status: 200,
headers: {
"Content-Type": "application/json",
"Set-Cookie": `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`,
},
})
} catch (error) { } catch (error) {
console.error("Logout error:", error) console.error("Logout error:", error)
return NextResponse.json( return new Response(
{ error: "Logout failed." }, JSON.stringify({ error: "Logout failed." }),
{ status: 500 } { status: 500, headers: { "Content-Type": "application/json" } }
) )
} }
} }
+5 -5
View File
@@ -196,11 +196,11 @@ export async function GET(request: NextRequest) {
trends, trends,
recentLeads: mappedLeads.slice(0, 10), recentLeads: mappedLeads.slice(0, 10),
statusDistribution: [ statusDistribution: [
{ name: "Open", value: currentCounts.open, color: "#FFFFFF" }, { name: "Open", value: currentCounts.open, color: "#3b82f6" },
{ name: "Contacted", value: currentCounts.contacted, color: "#dc2626" }, { name: "Contacted", value: currentCounts.contacted, color: "#f59e0b" },
{ name: "Pending", value: currentCounts.pending, color: "#1e3a8a" }, { name: "Pending", value: currentCounts.pending, color: "#8b5cf6" },
{ name: "Closed", value: currentCounts.closed, color: "#60a5fa" }, { name: "Closed", value: currentCounts.closed, color: "#10b981" },
{ name: "Ignored", value: currentCounts.ignored, color: "#000000" }, { name: "Ignored", value: currentCounts.ignored, color: "#6B7280" },
], ],
periodLabel: periodLabels[period] ?? "Selected period", 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 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const result = await query( const result = await query(
`SELECT u.id, u.first_name, u.last_name, u.email `SELECT u.id, u.first_name, u.last_name, u.email,
ur.role_id, r.name AS role_name, r.display_name AS role_display
FROM users u FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id
LEFT JOIN roles r ON r.id = ur.role_id
WHERE u.deleted_at IS NULL AND u.id != $1 WHERE u.deleted_at IS NULL AND u.id != $1
ORDER BY u.first_name ASC`, ORDER BY u.first_name ASC`,
[user.id], [user.id],
@@ -19,6 +22,7 @@ export async function GET() {
id: r.id, id: r.id,
name: `${r.first_name} ${r.last_name}`, name: `${r.first_name} ${r.last_name}`,
email: r.email, email: r.email,
role: r.role_display || r.role_name || "",
})) }))
return NextResponse.json({ users }) return NextResponse.json({ users })
+37 -10
View File
@@ -9,11 +9,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { id } = await params const { id } = await params
const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, participantNotes } = await request.json() const { title, description, eventType, startTime, endTime, durationMinutes, status, participantId, developerId, participantNotes, clientName, clientEmail, clientPhone } = await request.json()
const existing = await query( const existing = await query(
`SELECT e.user_id, e.participant_id, e.title, e.description, e.participant_notes, e.event_type, `SELECT e.user_id, e.participant_id, e.developer_id, e.lead_id, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.start_time, e.end_time, e.duration_minutes, e.status, e.client_name, e.client_email, e.client_phone,
u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.first_name AS u_first, u.last_name AS u_last,
p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last p.email AS p_email, p.first_name AS p_first, p.last_name AS p_last
FROM scheduled_events e FROM scheduled_events e
@@ -31,8 +31,9 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const old = existing.rows[0] const old = existing.rows[0]
const isCreator = old.user_id === user.id const isCreator = old.user_id === user.id
const isParticipant = old.participant_id === user.id const isParticipant = old.participant_id === user.id
const isDeveloper = old.developer_id === user.id
if (!isCreator && !isParticipant) { if (!isCreator && !isParticipant && !isDeveloper) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }) return NextResponse.json({ error: "Forbidden" }, { status: 403 })
} }
@@ -42,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
(startTime !== undefined && startTime !== old.start_time) || (startTime !== undefined && startTime !== old.start_time) ||
(endTime !== undefined && endTime !== old.end_time) || (endTime !== undefined && endTime !== old.end_time) ||
(durationMinutes !== undefined && durationMinutes !== old.duration_minutes) || (durationMinutes !== undefined && durationMinutes !== old.duration_minutes) ||
(participantId !== undefined && participantId !== old.participant_id) (participantId !== undefined && participantId !== old.participant_id) ||
(developerId !== undefined && developerId !== old.developer_id)
)) { )) {
return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 }) return NextResponse.json({ error: "Only the creator can edit event details" }, { status: 403 })
} }
@@ -53,14 +55,18 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
description = COALESCE($2, description), description = COALESCE($2, description),
participant_notes = COALESCE($3, participant_notes), participant_notes = COALESCE($3, participant_notes),
event_type = COALESCE($4, event_type), event_type = COALESCE($4, event_type),
start_time = COALESCE($5, start_time), start_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($5, start_time) END,
end_time = COALESCE($6, end_time), end_time = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($6, end_time) END,
duration_minutes = COALESCE($7, duration_minutes), duration_minutes = CASE WHEN $4::varchar = 'website_creation' THEN NULL ELSE COALESCE($7, duration_minutes) END,
status = COALESCE($8, status), status = COALESCE($8, status),
participant_id = COALESCE($9, participant_id), participant_id = COALESCE($9, participant_id),
developer_id = COALESCE($10, developer_id),
client_name = COALESCE($11, client_name),
client_email = COALESCE($12, client_email),
client_phone = COALESCE($13, client_phone),
updated_at = NOW() updated_at = NOW()
WHERE id = $10 WHERE id = $14
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`, RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[ [
title || null, title || null,
description ?? null, description ?? null,
@@ -71,6 +77,10 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
durationMinutes ?? null, durationMinutes ?? null,
status || null, status || null,
participantId ?? null, participantId ?? null,
developerId ?? null,
clientName ?? null,
clientEmail ?? null,
clientPhone ?? null,
id, id,
], ],
user.id, user.id,
@@ -78,6 +88,17 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const r = result.rows[0] const r = result.rows[0]
// Auto-close lead when developer marks event as completed
if (status === "completed" && r.lead_id) {
const stageResult = await query("SELECT id FROM lead_stages WHERE name = 'Closed Won'")
if (stageResult.rows.length > 0) {
await query(
`UPDATE leads SET stage_id = $1, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`,
[stageResult.rows[0].id, r.lead_id],
)
}
}
const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time || const isChanged = r.start_time !== old.start_time || r.end_time !== old.end_time ||
r.title !== old.title || r.event_type !== old.event_type || r.title !== old.title || r.event_type !== old.event_type ||
r.status !== old.status r.status !== old.status
@@ -108,12 +129,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
) )
const creatorInfo = creatorResult.rows[0] const creatorInfo = creatorResult.rows[0]
const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null const participantInfo = old.participant_id ? { id: old.participant_id, name: `${old.p_first} ${old.p_last}` || old.participant_id, email: old.p_email || null } : null
const developerInfo = old.developer_id ? { id: old.developer_id, name: "", email: "" } : null
return NextResponse.json({ return NextResponse.json({
event: { event: {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -126,7 +149,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
status: r.status, status: r.status,
creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" }, creator: creatorInfo ? { id: creatorInfo.id, name: `${creatorInfo.first_name} ${creatorInfo.last_name}`, email: creatorInfo.email, role: creatorInfo.role_display || creatorInfo.role_name, avatar: creatorInfo.avatar_url } : { id: old.user_id, name: "Unknown" },
participant: participantInfo, participant: participantInfo,
developer: developerInfo,
lead: null, lead: null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
}, },
}) })
+73 -15
View File
@@ -13,27 +13,33 @@ export async function GET(request: NextRequest) {
const end = searchParams.get("end") const end = searchParams.get("end")
const status = searchParams.get("status") const status = searchParams.get("status")
let sql = `SELECT e.id, e.user_id, e.participant_id, e.lead_id, e.conversation_id, let sql = `SELECT e.id, e.user_id, e.participant_id, e.developer_id, e.lead_id, e.conversation_id,
e.title, e.description, e.participant_notes, e.event_type, e.title, e.description, e.participant_notes, e.event_type,
e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at, e.start_time, e.end_time, e.duration_minutes, e.status, e.created_at,
e.client_name, e.client_email, e.client_phone,
u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar, u.id AS u_id, u.first_name AS u_first, u.last_name AS u_last, u.email AS u_email, u.avatar_url AS u_avatar,
ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display, ur.role_id AS u_role_id, r.name AS u_role_name, r.display_name AS u_role_display,
p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar, p.id AS p_id, p.first_name AS p_first, p.last_name AS p_last, p.email AS p_email, p.avatar_url AS p_avatar,
pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display, pr.role_id AS p_role_id, pr2.name AS p_role_name, pr2.display_name AS p_role_display,
d.id AS d_id, d.first_name AS d_first, d.last_name AS d_last, d.email AS d_email, d.avatar_url AS d_avatar,
dr.role_id AS d_role_id, dr2.name AS d_role_name, dr2.display_name AS d_role_display,
l.id AS l_id, l.company_name, l.contact_name l.id AS l_id, l.company_name, l.contact_name
FROM scheduled_events e FROM scheduled_events e
JOIN users u ON u.id = e.user_id JOIN users u ON u.id = e.user_id
LEFT JOIN users p ON p.id = e.participant_id LEFT JOIN users p ON p.id = e.participant_id
LEFT JOIN users d ON d.id = e.developer_id
LEFT JOIN leads l ON l.id = e.lead_id LEFT JOIN leads l ON l.id = e.lead_id
LEFT JOIN user_roles ur ON ur.user_id = e.user_id LEFT JOIN user_roles ur ON ur.user_id = e.user_id
LEFT JOIN roles r ON r.id = ur.role_id LEFT JOIN roles r ON r.id = ur.role_id
LEFT JOIN user_roles pr ON pr.user_id = e.participant_id LEFT JOIN user_roles pr ON pr.user_id = e.participant_id
LEFT JOIN roles pr2 ON pr2.id = pr.role_id` LEFT JOIN roles pr2 ON pr2.id = pr.role_id
LEFT JOIN user_roles dr ON dr.user_id = e.developer_id
LEFT JOIN roles dr2 ON dr2.id = dr.role_id`
const params: unknown[] = [] const params: unknown[] = []
let idx = 1 let idx = 1
if (user.role !== "super_admin") { if (user.role !== "super_admin") {
sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx}` sql += ` WHERE e.user_id = $${idx} OR e.participant_id = $${idx} OR e.developer_id = $${idx}`
params.push(user.id) params.push(user.id)
idx++ idx++
} else { } else {
@@ -41,13 +47,13 @@ export async function GET(request: NextRequest) {
} }
if (start) { if (start) {
sql += ` AND e.start_time >= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time >= $${idx})`
params.push(start) params.push(start)
idx++ idx++
} }
if (end) { if (end) {
sql += ` AND e.start_time <= $${idx}` sql += ` AND (e.start_time IS NULL OR e.start_time <= $${idx})`
params.push(end) params.push(end)
idx++ idx++
} }
@@ -66,6 +72,7 @@ export async function GET(request: NextRequest) {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -78,7 +85,11 @@ export async function GET(request: NextRequest) {
status: r.status, status: r.status,
creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar }, creator: { id: r.u_id, name: `${r.u_first} ${r.u_last}`, email: r.u_email, role: r.u_role_display || r.u_role_name, avatar: r.u_avatar },
participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null, participant: r.p_id ? { id: r.p_id, name: `${r.p_first} ${r.p_last}`, email: r.p_email, role: r.p_role_display || r.p_role_name, avatar: r.p_avatar } : null,
developer: r.d_id ? { id: r.d_id, name: `${r.d_first} ${r.d_last}`, email: r.d_email, role: r.d_role_display || r.d_role_name, avatar: r.d_avatar } : null,
lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null, lead: r.l_id ? { id: r.l_id, companyName: r.company_name, contactName: r.contact_name } : null,
clientName: r.client_name || null,
clientEmail: r.client_email || null,
clientPhone: r.client_phone || null,
createdAt: r.created_at, createdAt: r.created_at,
})) }))
@@ -95,36 +106,55 @@ export async function POST(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const { const {
participantId, leadId, conversationId, participantId, developerId, leadId, conversationId,
title, description, eventType, title, description, eventType,
startTime, endTime, durationMinutes, startTime, endTime, durationMinutes,
clientName, clientEmail, clientPhone,
} = await request.json() } = await request.json()
if (!title || !startTime) { if (!title) {
return NextResponse.json({ error: "Title and start time are required" }, { status: 400 }) return NextResponse.json({ error: "Title is required" }, { status: 400 })
}
if (eventType !== "website_creation" && !startTime) {
return NextResponse.json({ error: "Start time is required for this event type" }, { status: 400 })
} }
const result = await query( 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) `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) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
RETURNING id, user_id, participant_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, status, created_at`, RETURNING id, user_id, participant_id, developer_id, lead_id, conversation_id, title, description, participant_notes, event_type, start_time, end_time, duration_minutes, client_name, client_email, client_phone, status, created_at`,
[ [
user.id, user.id,
participantId || null, participantId || null,
developerId || null,
leadId || null, leadId || null,
conversationId || null, conversationId || null,
title, title,
description || null, description || null,
eventType || "meeting", eventType || "website_creation",
startTime, startTime || null,
endTime || null, eventType === "website_creation" ? null : (endTime || null),
durationMinutes || null, eventType === "website_creation" ? null : (durationMinutes || null),
clientName || null,
clientEmail || null,
clientPhone || null,
], ],
user.id, user.id,
) )
const r = result.rows[0] 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) { if (participantId && participantId !== user.id) {
await query( await query(
`INSERT INTO notifications (user_id, type, title, description, link, context_id, context_type) `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( const participantResult = participantId ? await query(
`SELECT email, first_name, last_name FROM users WHERE id = $1`, `SELECT email, first_name, last_name FROM users WHERE id = $1`,
[participantId], [participantId],
) : null ) : null
const participant = participantResult?.rows[0] || 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({ sendEventConfirmation({
creatorName: `${user.firstName} ${user.lastName}`, creatorName: `${user.firstName} ${user.lastName}`,
creatorEmail: user.email, creatorEmail: user.email,
@@ -165,6 +218,7 @@ export async function POST(request: NextRequest) {
id: r.id, id: r.id,
userId: r.user_id, userId: r.user_id,
participantId: r.participant_id, participantId: r.participant_id,
developerId: r.developer_id,
leadId: r.lead_id, leadId: r.lead_id,
conversationId: r.conversation_id, conversationId: r.conversation_id,
title: r.title, title: r.title,
@@ -177,7 +231,11 @@ export async function POST(request: NextRequest) {
status: r.status, status: r.status,
creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role }, creator: { id: user.id, name: `${user.firstName} ${user.lastName}`, email: user.email, role: user.role },
participant: participantId ? { id: participantId, name: participant ? `${participant.first_name} ${participant.last_name}` : participantId, email: participant?.email || null } : null, participant: 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, 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, createdAt: r.created_at,
}, },
}, { status: 201 }) }, { status: 201 })
+1 -5
View File
@@ -15,11 +15,7 @@ export async function GET(request: NextRequest) {
const pos = searchParams.get("pos") || "" const pos = searchParams.get("pos") || ""
if (!TENOR_API_KEY) { if (!TENOR_API_KEY) {
return NextResponse.json({ return NextResponse.json({ results: [], noKey: true })
results: [],
error: "TENOR_API_KEY not configured",
noKey: true,
})
} }
const endpoint = q const endpoint = q
+2 -2
View File
@@ -12,7 +12,7 @@ export async function GET() {
[user.id], [user.id],
) )
const websiteTheme = result.rows[0]?.website_theme || "spidey" const websiteTheme = result.rows[0]?.website_theme || "default"
return NextResponse.json({ websiteTheme }) return NextResponse.json({ websiteTheme })
} catch (error) { } catch (error) {
console.error("Website theme GET error:", error) console.error("Website theme GET error:", error)
@@ -26,7 +26,7 @@ export async function PUT(request: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
const body = await request.json() const body = await request.json()
const theme = body.websiteTheme || "spidey" const theme = body.websiteTheme || "default"
await query( await query(
`UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`, `UPDATE users SET preferences = preferences || $2::jsonb WHERE id = $1`,
+40 -40
View File
@@ -53,24 +53,24 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
if (!joined) { if (!joined) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{connectionTimeout ? ( {connectionTimeout ? (
<> <>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">This call is no longer available.</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">This call is no longer available.</h1>
</> </>
) : !isReady ? ( ) : !isReady ? (
<> <>
<div className="flex flex-col items-center gap-4 py-6"> <div className="flex flex-col items-center gap-4 py-6">
<Loader2 className="h-8 w-8 animate-spin text-[#CC0000]" /> <Loader2 className="h-8 w-8 animate-spin text-[#C84B4B]" />
<p className="text-[#888888] text-sm">Connecting to call...</p> <p className="text-[#8A9078] text-sm">Connecting to call...</p>
</div> </div>
</> </>
) : ( ) : (
<> <>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3">Join Call</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">Join Call</h1>
{micError && ( {micError && (
<p className="text-[#CC0000] text-xs mb-4">{micError}</p> <p className="text-[#C84B4B] text-xs mb-4">{micError}</p>
)} )}
<input <input
type="text" type="text"
@@ -78,12 +78,12 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
onChange={(e) => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }} onKeyDown={(e) => { if (e.key === "Enter") handleJoin() }}
placeholder="Enter your name" placeholder="Enter your name"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-4 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/> />
<button <button
onClick={handleJoin} onClick={handleJoin}
disabled={!displayName.trim()} disabled={!displayName.trim()}
className="w-full bg-[#CC0000] hover:bg-[#990000] disabled:bg-[#CCCCCC] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200" className="w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 disabled:bg-[#8A9078] disabled:cursor-not-allowed dark:bg-[#FF1111] dark:hover:bg-[#CC0000] dark:disabled:bg-[#333333] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
> >
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
Join Call Join Call
@@ -96,14 +96,14 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
{callState === "calling" && ( {callState === "calling" && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Calling</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Calling</h1>
<p className="text-[#888888] text-sm animate-pulse">Connecting to call room...</p> <p className="text-[#8A9078] text-sm animate-pulse">Connecting to call room...</p>
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"> <button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" /> <PhoneOff className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -112,21 +112,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "waiting" && ( {callState === "waiting" && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Waiting for participant</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Waiting for participant</h1>
<p className="text-[#888888] text-sm animate-pulse">Share the call link to invite someone...</p> <p className="text-[#8A9078] text-sm animate-pulse">Share the call link to invite someone...</p>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="mt-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3"> <div className="mt-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2"> <div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
Participants ({participants.length}) Participants ({participants.length})
</div> </div>
{participants.map((p) => ( {participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div> <div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))} ))}
</div> </div>
)} )}
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"> <button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" /> <PhoneOff className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -135,21 +135,21 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "participant_joined" && ( {callState === "participant_joined" && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Participant joined</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Participant joined</h1>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3"> <div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2"> <div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
Participants ({participants.length}) Participants ({participants.length})
</div> </div>
{participants.map((p) => ( {participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div> <div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))} ))}
</div> </div>
)} )}
<p className="text-[#888888] text-sm animate-pulse">Connecting...</p> <p className="text-[#8A9078] text-sm animate-pulse">Connecting...</p>
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"> <button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" /> <PhoneOff className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -158,10 +158,10 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "connecting" && ( {callState === "connecting" && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Connecting</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Connecting</h1>
<p className="text-[#888888] text-sm animate-pulse">Establishing secure connection...</p> <p className="text-[#8A9078] text-sm animate-pulse">Establishing secure connection...</p>
<div className="flex justify-center mt-6"> <div className="flex justify-center mt-6">
<button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"> <button onClick={handleEnd} className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200">
<PhoneOff className="h-5 w-5" /> <PhoneOff className="h-5 w-5" />
</button> </button>
</div> </div>
@@ -170,37 +170,37 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{callState === "connected" && ( {callState === "connected" && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-1">Connected</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-1">Connected</h1>
{participants.length > 0 && ( {participants.length > 0 && (
<div className="mb-4 bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3"> <div className="mb-4 bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3">
<div className="flex items-center gap-2 text-xs text-[#888888] mb-2"> <div className="flex items-center gap-2 text-xs text-[#8A9078] mb-2">
<Users className="h-3 w-3" /> <Users className="h-3 w-3" />
Participants ({participants.length}) Participants ({participants.length})
</div> </div>
{participants.map((p) => ( {participants.map((p) => (
<div key={p.id} className="text-sm text-[#111111] dark:text-white text-left">{p.label}</div> <div key={p.id} className="text-sm text-[#2D3020] dark:text-white text-left">{p.label}</div>
))} ))}
</div> </div>
)} )}
<div className="text-[#CC0000] font-mono text-lg mb-6"> <div className="text-[#C84B4B] font-mono text-lg mb-6">
{formatDuration(callDuration)} {formatDuration(callDuration)}
</div> </div>
<div className="flex justify-center gap-4"> <div className="flex justify-center gap-4">
<button <button
onClick={toggleSpeaker} onClick={toggleSpeaker}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`} className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isSpeakerOn ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
> >
{isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />} {isSpeakerOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}
</button> </button>
<button <button
onClick={toggleMute} onClick={toggleMute}
className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#0033CC] dark:bg-[#1144FF]' : 'bg-[#444444] dark:bg-[#333333]'}`} className={`w-14 h-14 rounded-full flex items-center justify-center text-white font-bold transition-all duration-200 ${isMuted ? 'bg-[#5A8FC4] dark:bg-[#1144FF]' : 'bg-[#8A9078] dark:bg-[#333333]'}`}
> >
{isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />} {isMuted ? <MicOff className="h-5 w-5" /> : <Mic className="h-5 w-5" />}
</button> </button>
<button <button
onClick={handleEnd} onClick={handleEnd}
className="w-14 h-14 rounded-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200" className="w-14 h-14 rounded-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] flex items-center justify-center text-white font-bold transition-all duration-200"
> >
<PhoneOff className="h-5 w-5" /> <PhoneOff className="h-5 w-5" />
</button> </button>
@@ -210,15 +210,15 @@ export default function CallRoomPage({ params }: { params: Promise<{ roomId: str
{(callState === "ended" || callState === "idle") && ( {(callState === "ended" || callState === "idle") && (
<div> <div>
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-4">Call ended</h1> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-4">Call ended</h1>
{callDuration > 0 && ( {callDuration > 0 && (
<p className="text-[#888888] text-sm mb-6">Duration: {formatDuration(callDuration)}</p> <p className="text-[#8A9078] text-sm mb-6">Duration: {formatDuration(callDuration)}</p>
)} )}
</div> </div>
)} )}
{error && ( {error && (
<p className="text-[#CC0000] text-xs mt-4">{error}</p> <p className="text-[#C84B4B] text-xs mt-4">{error}</p>
)} )}
</div> </div>
</div> </div>
+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=Raleway:wght@800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&family=Audiowide&display=swap');
@import "../styles/ai-assistant.css";
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@@ -76,41 +78,44 @@
} }
:root { :root {
--background: 220 14% 84%; --background: 60 20% 95%;
--foreground: 222.2 84% 4.9%; --foreground: 71 20% 16%;
--card: 220 10% 91%; --card: 60 29% 97%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 71 20% 16%;
--popover: 220 14% 96%; --popover: 60 29% 97%;
--popover-foreground: 222.2 84% 4.9%; --popover-foreground: 71 20% 16%;
--primary: 120 100% 50%; --primary: 0 53% 54%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 220 14% 88%; --secondary: 210 47% 56%;
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 0 0% 100%;
--muted: 220 14% 88%; --muted: 75 10% 93%;
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 75 10% 52%;
--accent: 220 14% 88%; --accent: 159 32% 43%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 0 0% 100%;
--destructive: 0 84.2% 60.2%; --destructive: 0 53% 54%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 100%;
--border: 214.3 31.8% 91.4%; --border: 80 13% 82%;
--input: 214.3 31.8% 91.4%; --input: 80 13% 82%;
--ring: 221.2 83.2% 53.3%; --ring: 0 53% 54%;
--sidebar: 216 12% 92%; --sidebar: 80 17% 92%;
--sidebar-foreground: 0 0% 20%; --sidebar-foreground: 71 20% 16%;
--sidebar-primary: 0 91.2% 59.8%; --sidebar-primary: 0 53% 54%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 218 16% 87%; --sidebar-accent: 80 17% 92%;
--sidebar-accent-foreground: 0 0% 20%; --sidebar-accent-foreground: 71 20% 16%;
--sidebar-border: 220 11% 84%; --sidebar-border: 80 13% 82%;
--sidebar-ring: 0 76.3% 48%; --sidebar-ring: 0 53% 54%;
--radius: 0.5rem; --radius: 0.5rem;
--theme-primary: hsl(var(--primary)); --theme-primary: hsl(var(--primary));
--event-meeting: 217 91% 55%; --color-blue: 210 47% 56%;
--color-teal: 159 32% 43%;
--color-red-tint: 0 62% 95%;
--color-blue-tint: 208 54% 93%;
--color-teal-tint: 157 35% 91%;
--color-avatar: 102 21% 52%;
--event-call: 160 84% 39%; --event-call: 160 84% 39%;
--event-chat: 271 81% 53%;
--event-follow_up: 38 92% 48%; --event-follow_up: 38 92% 48%;
--event-demo: 346 77% 48%; --event-website_creation: 263 70% 50%;
--event-other: 215 20% 55%;
} }
.dark { .dark {
@@ -731,3 +736,4 @@ main {
.checkbox-text { color: rgba(232,232,239,0.38); } .checkbox-text { color: rgba(232,232,239,0.38); }
.footer-text { color: rgba(232,232,239,0.2); } .footer-text { color: rgba(232,232,239,0.2); }
+12 -12
View File
@@ -7,9 +7,9 @@ interface Props {
function ErrorPage({ message }: { message: string }) { function ErrorPage({ message }: { message: string }) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3"> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
{message} {message}
</h1> </h1>
</div> </div>
@@ -42,24 +42,24 @@ export default async function JoinPage({ params }: Props) {
} }
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#CC0000]/10 to-[#990000]/10 p-4"> <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#C84B4B]/10 to-[#C84B4B]/10 p-4">
<div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-lg text-center"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-8 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-lg text-center">
<h1 className="text-2xl font-bold text-[#111111] dark:text-white mb-3"> <h1 className="text-2xl font-bold text-[#2D3020] dark:text-white mb-3">
You&apos;re Invited! You&apos;re Invited!
</h1> </h1>
<p className="text-[#888888] text-sm mb-6"> <p className="text-[#8A9078] text-sm mb-6">
Someone wants to connect with you on our platform. Someone wants to connect with you on our platform.
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left"> <div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-4 mb-6 text-left">
<p className="text-xs text-[#888888] mb-1">Contact Number</p> <p className="text-xs text-[#8A9078] mb-1">Contact Number</p>
<p className="text-sm font-medium text-[#111111] dark:text-white">{invite.phone}</p> <p className="text-sm font-medium text-[#2D3020] dark:text-white">{invite.phone}</p>
</div> </div>
<p className="text-xs text-[#888888] mb-6"> <p className="text-xs text-[#8A9078] mb-6">
Create an account to start making free voice calls to this person and others on the platform. Create an account to start making free voice calls to this person and others on the platform.
</p> </p>
<a <a
href="/register" href="/register"
className="block w-full bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200" className="block w-full bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-3 transition-all duration-200"
> >
Create Account Create Account
</a> </a>
+2 -1
View File
@@ -4,6 +4,7 @@ import { ThemeProvider } from "@/providers/theme-provider"
import { WebsiteThemeProvider } from "@/providers/website-theme-provider" import { WebsiteThemeProvider } from "@/providers/website-theme-provider"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import "./globals.css" import "./globals.css"
import "../../Web_Backgrounds/default-theme.css"
import "../../Web_Backgrounds/spidey-theme.css" import "../../Web_Backgrounds/spidey-theme.css"
const inter = Inter({ const inter = Inter({
@@ -32,7 +33,7 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.variable} min-h-screen antialiased`}> <body className={`${inter.variable} min-h-screen antialiased`}>
<ThemeProvider attribute="class" defaultTheme="dark" disableTransitionOnChange> <ThemeProvider attribute="class" defaultTheme="light" disableTransitionOnChange>
<WebsiteThemeProvider> <WebsiteThemeProvider>
{children} {children}
<Toaster /> <Toaster />
+16 -2
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useEffect, useRef } from "react" import { useState, useEffect, useRef, Suspense } from "react"
import { useSearchParams, useRouter } from "next/navigation" import { useSearchParams, useRouter } from "next/navigation"
import { Eye, EyeOff, Loader2 } from "lucide-react" import { Eye, EyeOff, Loader2 } from "lucide-react"
@@ -13,7 +13,20 @@ const waves = [
] ]
export default function LoginPage() { export default function LoginPage() {
return (
<Suspense fallback={
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
</div>
}>
<LoginForm />
</Suspense>
)
}
function LoginForm() {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
@@ -214,7 +227,8 @@ export default function LoginPage() {
}) })
if (res.ok) { if (res.ok) {
router.push("/dashboard") const redirectTo = searchParams.get("redirect") || "/dashboard"
router.push(redirectTo)
} else { } else {
const data = await res.json().catch(() => ({})) const data = await res.json().catch(() => ({}))
setError(data.error || "Invalid email or password.") setError(data.error || "Invalid email or password.")
+244 -152
View File
@@ -1,33 +1,80 @@
"use client" "use client"
import { useState, useRef, useEffect, Fragment } from "react" import { useState, useRef, useEffect, Fragment, forwardRef, useImperativeHandle, useCallback } from "react"
import { Send, Bot, User, RefreshCw, AlertCircle, Check, Terminal } from "lucide-react" import { Bot, Terminal } from "lucide-react"
function linkifyText(text: string) { function linkifyText(text: string) {
const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/ const urlRegex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)\]}>])/
const parts = text.split(urlRegex) const parts = text.split(urlRegex)
return parts.map((part, i) => { return parts.map((part, i) => {
if (part.startsWith("http://") || part.startsWith("https://")) { if (part.startsWith("http://") || part.startsWith("https://")) {
return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#1BB0CE] hover:text-[#1BB0CE]/80">{part}</a> return <a key={i} href={part} target="_blank" rel="noopener noreferrer" className="underline text-[#f97316] hover:text-[#f97316]/80">{part}</a>
} }
return <Fragment key={i}>{part}</Fragment> return <Fragment key={i}>{part}</Fragment>
}) })
} }
function formatContent(text: string) {
const lines = text.split("\n")
return lines.map((line, i) => {
const trimmed = line.trim()
if (trimmed.startsWith("•") || trimmed.startsWith("-")) {
const content = trimmed.replace(/^[•\-]\s*/, "")
return (
<div key={i} className="flex items-start gap-2.5 my-1.5">
<span className="w-1.5 h-1.5 bg-[#f97316] rounded-sm inline-block mt-2 flex-shrink-0" />
<span>{linkifyText(content)}</span>
</div>
)
}
if (line === "") return <div key={i} className="h-2" />
return <p key={i} className="my-1">{linkifyText(line)}</p>
})
}
interface ChatMessage { interface ChatMessage {
role: "user" | "assistant" role: "user" | "assistant"
content: string content: string
} }
export function AIChat() { interface AIChatProps {
onMessageSent?: (msg: string) => void
}
const quickActions = [
{ icon: "✉️", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Cold Email Template", desc: "Generate targeted outreach emails", prompt: "Write a cold email template for a Software Developer" },
{ icon: "🛡️", iconBg: "bg-[#3b82f6]/10", iconColor: "text-[#3b82f6]", title: "Handle Objections", desc: "Get scripts for common objections", prompt: "Give me objection handling scripts for sales" },
{ icon: "🎯", iconBg: "bg-[#8b5cf6]/10", iconColor: "text-[#8b5cf6]", title: "Target Industry", desc: "Find leads in specific industries", prompt: "How do I target leads in the tech industry" },
{ icon: "📋", iconBg: "bg-[#22c55e]/10", iconColor: "text-[#22c55e]", title: "Build Lead List", desc: "Strategies to grow your pipeline", prompt: "Help me build a lead list strategy" },
{ icon: "📞", iconBg: "bg-[#f97316]/10", iconColor: "text-[#f97316]", title: "Call Scripts", desc: "Proven phone sales scripts", prompt: "Give me a cold call script for outreach" },
{ icon: "📊", iconBg: "bg-[#ec4899]/10", iconColor: "text-[#ec4899]", title: "Sales Strategy", desc: "Industry specific sales tactics", prompt: "What sales strategies work best per industry" },
]
const commandPills = [
{ icon: "📋", label: "Lists", prompt: "lists" },
{ icon: "👥", label: "Leads", prompt: "leads" },
{ icon: "💡", label: "Tips", prompt: "Give me some sales tips" },
{ icon: "✉️", label: "Templates", prompt: "Show me email templates" },
]
export const AIChat = forwardRef<{ fillInput: (text: string) => void }, AIChatProps>(({ onMessageSent }, ref) => {
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [input, setInput] = useState("") const [input, setInput] = useState("")
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting") const [bootState, setBootState] = useState<"booting" | "ready" | "error">("booting")
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const hasUserMessage = messages.some(m => m.role === "user")
const checkServer = async () => { useImperativeHandle(ref, () => ({
fillInput(text: string) {
setInput(text)
setTimeout(() => textareaRef.current?.focus(), 50)
},
}), [])
const checkServer = useCallback(async () => {
try { try {
const res = await fetch("/api/ai/chat", { const res = await fetch("/api/ai/chat", {
method: "POST", method: "POST",
@@ -42,7 +89,7 @@ export function AIChat() {
} catch { } catch {
setTimeout(checkServer, 2000) setTimeout(checkServer, 2000)
} }
} }, [])
useEffect(() => { useEffect(() => {
fetch("/api/ai/jobs") fetch("/api/ai/jobs")
@@ -74,18 +121,19 @@ export function AIChat() {
]) ])
}) })
checkServer() checkServer()
}, []) }, [checkServer])
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }) messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
}, [messages]) }, [messages])
const sendMessage = async () => { const sendMessage = useCallback(async (text?: string) => {
const msg = input.trim() const msg = (text || input).trim()
if (!msg || loading) return if (!msg || loading) return
setInput("") setInput("")
setError("") setError("")
onMessageSent?.(msg)
setMessages((prev) => [...prev, { role: "user", content: msg }]) setMessages((prev) => [...prev, { role: "user", content: msg }])
setLoading(true) setLoading(true)
@@ -113,167 +161,211 @@ export function AIChat() {
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [input, loading, onMessageSent])
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault()
sendMessage() sendMessage()
} }
}, [sendMessage])
const handleTextareaInput = useCallback((e: React.FormEvent<HTMLTextAreaElement>) => {
const t = e.target as HTMLTextAreaElement
t.style.height = "auto"
t.style.height = t.scrollHeight + "px"
}, [])
const handleQuickAction = useCallback((prompt: string) => {
setInput(prompt)
setTimeout(() => sendMessage(prompt), 50)
}, [sendMessage])
const handleCommandPill = useCallback((prompt: string) => {
setInput(prompt)
setTimeout(() => sendMessage(prompt), 50)
}, [sendMessage])
if (bootState === "booting") {
return (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center gap-5">
<p className="text-sm text-[#6b7280] animate-pulse">Servers booting...</p>
<div className="h-[56px] w-[110px] bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl flex items-center justify-center overflow-hidden">
<div className="robot-walk relative">
<svg width="40" height="36" viewBox="0 0 40 36" fill="none">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#f97316" opacity="0.9"/>
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#f97316" opacity="0.7"/>
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#f97316" opacity="0.7"/>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0f1117"/>
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#f97316"/>
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0f1117"/>
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#f97316" opacity="0.8"/>
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#f97316" opacity="0.8"/>
</svg>
</div>
</div>
</div>
</div>
)
} }
const bootOverlay = ( if (bootState === "ready" && !hasUserMessage) {
<div className="flex-1 flex items-center justify-center"> return (
<style>{` <div className="flex-1 flex flex-col overflow-y-auto" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
@keyframes walk { <div className="flex-1 flex items-center justify-center">
0%, 100% { transform: translateX(0) translateY(0); } <div className="max-w-lg mx-auto pt-12 pb-6 px-4">
25% { transform: translateX(12px) translateY(-4px); } <div className="float-in">
50% { transform: translateX(24px) translateY(0); } <div className="w-16 h-16 rounded-2xl mx-auto mb-6 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center shadow-[0_0_40px_rgba(249,115,22,0.3)]">
75% { transform: translateX(12px) translateY(-4px); } <Bot className="h-8 w-8 text-white" />
} </div>
@keyframes legLeft { <h2 className="text-white font-bold text-2xl text-center mb-2">What can I help you with?</h2>
0%, 100% { transform: rotate(-10deg); } <p className="text-[#6b7280] text-sm text-center mb-8 leading-relaxed">Your AI sales assistant is ready. Choose a quick action or type your question below.</p>
50% { transform: rotate(10deg); } </div>
} <div className="grid grid-cols-2 gap-3">
@keyframes legRight { {quickActions.map((action, i) => (
0%, 100% { transform: rotate(10deg); } <div
50% { transform: rotate(-10deg); } key={i}
} onClick={() => handleQuickAction(action.prompt)}
@keyframes armLeft { className="float-in bg-[#1a1d2e] hover:bg-[#1f2437] rounded-xl p-4 border border-[#ffffff0a] hover:border-[#f97316]/30 cursor-pointer transition-all duration-200 hover:shadow-[0_4px_20px_rgba(249,115,22,0.1)] hover:-translate-y-0.5"
0%, 100% { transform: rotate(15deg); } style={{ animationDelay: `${0.1 + i * 0.05}s` }}
50% { transform: rotate(-15deg); } >
} <div className={`w-9 h-9 rounded-lg ${action.iconBg} ${action.iconColor} flex items-center justify-center text-lg`}>{action.icon}</div>
@keyframes armRight { <h3 className="text-white text-sm font-medium mt-3">{action.title}</h3>
0%, 100% { transform: rotate(-15deg); } <p className="text-[#6b7280] text-xs mt-1">{action.desc}</p>
50% { transform: rotate(15deg); } </div>
} ))}
@keyframes blink { </div>
0%, 45%, 55%, 100% { height: 4px; } <div className="flex gap-2 justify-center mt-6 flex-wrap">
50% { height: 1px; } {commandPills.map((pill, i) => (
} <div
.robot-walk { animation: walk 0.6s ease-in-out infinite; } key={i}
.robot-leg-l { transform-origin: top center; animation: legLeft 0.3s ease-in-out infinite; } onClick={() => handleCommandPill(pill.prompt)}
.robot-leg-r { transform-origin: top center; animation: legRight 0.3s ease-in-out infinite; } className="bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/40 hover:bg-[#1f2437] rounded-full px-4 py-2 text-xs text-[#9ca3af] hover:text-[#f97316] transition-all duration-200 cursor-pointer flex items-center gap-1.5"
.robot-arm-l { transform-origin: top center; animation: armLeft 0.3s ease-in-out infinite; } >
.robot-arm-r { transform-origin: top center; animation: armRight 0.3s ease-in-out infinite; } <span>{pill.icon}</span>
.robot-eye { animation: blink 2s ease-in-out infinite; } <span>{pill.label}</span>
`}</style> </div>
<div className="flex flex-col items-center gap-4"> ))}
<p className="text-sm text-[#6a6a75]">Servers booting...</p> </div>
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center overflow-hidden"> </div>
<div className="robot-walk relative"> </div>
<svg width="40" height="36" viewBox="0 0 40 36" fill="none"> <div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
<rect x="10" y="2" width="20" height="16" rx="3" fill="#1BB0CE" opacity="0.9"/> <div className="max-w-3xl mx-auto">
<rect x="6" y="6" width="6" height="2" rx="1" className="robot-arm-l" fill="#1BB0CE" opacity="0.7"/> <div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
<rect x="28" y="6" width="6" height="2" rx="1" className="robot-arm-r" fill="#1BB0CE" opacity="0.7"/> <button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
<rect x="14" y="5" width="4" height="4" rx="1" fill="#0d1117"/> <textarea
<rect x="22" y="5" width="4" height="4" rx="1" fill="#0d1117"/> ref={textareaRef}
<circle cx="16" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/> value={input}
<circle cx="24" cy="7" r="1.5" className="robot-eye" fill="#1BB0CE"/> onChange={(e) => setInput(e.target.value)}
<rect x="17" y="10" width="6" height="2" rx="1" fill="#0d1117"/> onInput={handleTextareaInput}
<rect x="12" y="18" width="5" height="10" rx="2" className="robot-leg-l" fill="#1BB0CE" opacity="0.8"/> onKeyDown={handleKeyDown}
<rect x="23" y="18" width="5" height="10" rx="2" className="robot-leg-r" fill="#1BB0CE" opacity="0.8"/> placeholder="Ask for sales tips..."
</svg> rows={1}
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> )
) }
const readyOverlay = (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="h-[50px] w-[100px] bg-[#1a1a24] border border-[#2a2a35] rounded-lg flex items-center justify-center">
<Check className="h-6 w-6 text-green-400" />
</div>
<div className="text-center space-y-1">
<p className="text-xs text-[#6a6a75]">Try these commands:</p>
<div className="flex gap-2">
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> lists</code>
<code className="text-xs px-2 py-1 rounded bg-[#2a2a35] text-[#1BB0CE] flex items-center gap-1"><Terminal className="h-3 w-3" /> leads</code>
</div>
</div>
</div>
</div>
)
if (bootState === "booting") return bootOverlay
return ( return (
<div className="flex flex-col h-full"> <div className="flex-1 flex flex-col min-h-0">
{bootState === "ready" && readyOverlay} <div className="flex-1 overflow-y-auto px-4 py-6" style={{ backgroundImage: "radial-gradient(circle, #ffffff08 1px, transparent 1px)", backgroundSize: "24px 24px" }}>
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-thin"> {messages.map((msg, i) => (
{messages.map((msg, i) => ( <div key={i}>
<div key={i} className={`flex gap-3 ${msg.role === "user" ? "justify-end" : "justify-start"}`}> {msg.role === "assistant" ? (
{msg.role === "assistant" && ( <div className="flex gap-3 items-start">
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4 text-[#1BB0CE]" /> <Bot className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 max-w-[85%]">
<div className="text-[#e5e7eb] text-sm leading-7 whitespace-pre-wrap">{formatContent(msg.content)}</div>
</div>
<div className="text-[#4b5563] text-[10px] mt-1">AI Assistant</div>
</div>
</div>
) : (
<div className="flex gap-3 items-start flex-row-reverse">
<div className="flex-1 min-w-0 flex justify-end">
<div className="bg-gradient-to-br from-[#f97316] to-[#ea580c] rounded-2xl rounded-tr-sm px-5 py-4 max-w-[75%]">
<div className="text-white text-sm leading-7 whitespace-pre-wrap">{msg.content}</div>
</div>
</div>
</div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 items-start">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white text-sm flex-shrink-0">
<Bot className="h-4 w-4" />
</div> </div>
)} <div className="bg-[#1a1d2e] rounded-2xl rounded-tl-sm border border-[#ffffff08] border-l-2 border-l-[#f97316] px-5 py-4 inline-flex items-center gap-1.5">
<div <span className="w-2 h-2 rounded-full bg-[#f97316] dot-1" />
className={`max-w-[75%] rounded-lg px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${ <span className="w-2 h-2 rounded-full bg-[#f97316] dot-2" />
msg.role === "user" <span className="w-2 h-2 rounded-full bg-[#f97316] dot-3" />
? "bg-[#1BB0CE] text-white"
: "bg-[#1a1a24] text-[#c8c8d0] border border-[#2a2a35]"
}`}
>
{linkifyText(msg.content)}
</div>
{msg.role === "user" && (
<div className="h-8 w-8 rounded-full bg-[#1BB0CE] flex items-center justify-center flex-none">
<User className="h-4 w-4 text-white" />
</div> </div>
)}
</div>
))}
{loading && (
<div className="flex gap-3 justify-start">
<div className="h-8 w-8 rounded-full bg-[#1BB0CE]/20 flex items-center justify-center flex-none">
<Bot className="h-4 w-4 text-[#1BB0CE]" />
</div> </div>
<div className="max-w-[75%] rounded-lg px-4 py-2.5 bg-[#1a1a24] border border-[#2a2a35]"> )}
<svg className="h-4 w-4 animate-spin text-[#1BB0CE]" viewBox="0 0 24 24" fill="none"> <div ref={messagesEndRef} />
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/> </div>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div> </div>
<div className="sticky bottom-0 bg-[#0f1117]/95 backdrop-blur-md border-t border-[#ffffff08] px-4 py-4">
<div className="border-t border-[#2a2a35] p-4"> <div className="max-w-3xl mx-auto">
{error && ( {error && (
<div className="mb-2 text-xs text-red-400 flex items-center gap-1.5"> <div className="mb-2.5 text-xs text-red-400 flex items-center gap-1.5">
<AlertCircle className="h-3 w-3" /> <span></span>
{error} {error}
</div>
)}
<div className="bg-[#1a1d2e] rounded-2xl border border-[#ffffff0a] focus-within:border-[#f97316]/40 focus-within:shadow-[0_0_20px_rgba(249,115,22,0.08)] transition-all duration-200 flex items-end gap-3 px-4 py-3">
<button type="button" className="w-8 h-8 rounded-lg text-[#4b5563] hover:text-[#f97316] hover:bg-[#ffffff08] flex items-center justify-center transition-colors duration-200 flex-shrink-0 text-lg">📎</button>
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onInput={handleTextareaInput}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="bg-transparent flex-1 text-[#e5e7eb] text-sm placeholder-[#4b5563] resize-none outline-none min-h-[24px] max-h-[200px] overflow-y-auto leading-6"
/>
<button
type="button"
onClick={() => sendMessage()}
disabled={!input.trim()}
className={`w-9 h-9 rounded-xl flex-shrink-0 bg-gradient-to-br from-[#f97316] to-[#ea580c] flex items-center justify-center text-white transition-all duration-200 ${input.trim() ? "hover:shadow-[0_0_20px_rgba(249,115,22,0.4)] hover:scale-105 active:scale-95" : "opacity-40 cursor-not-allowed"}`}
>
</button>
</div>
<div className="flex justify-between items-center mt-2 px-1">
<span className="text-[#374151] text-[10px]">Shift + Enter for new line</span>
<span className="text-[#374151] text-[10px]">{input.length} / 2000</span>
</div> </div>
)}
<div className="flex gap-2">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask for sales tips..."
rows={1}
className="flex-1 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] placeholder-[#6a6a75] resize-none outline-none focus:border-[#1BB0CE]/50"
/>
<button
type="button"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="h-9 w-9 rounded-lg bg-[#1BB0CE] hover:bg-[#1BB0CE]/80 disabled:opacity-40 flex items-center justify-center flex-none transition-colors"
>
{loading ? (
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" opacity="0.25"/>
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/>
</svg>
) : <Send className="h-4 w-4" />}
</button>
</div> </div>
</div> </div>
</div> </div>
) )
} })
AIChat.displayName = "AIChat"
+7 -7
View File
@@ -39,32 +39,32 @@ export function JobSelector({ onSelect }: JobSelectorProps) {
<button <button
type="button" type="button"
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className="w-full flex items-center gap-2 bg-[#1a1a24] border border-[#2a2a35] rounded-lg px-3 py-2 text-sm text-[#e8e8ef] hover:border-[#1BB0CE]/50 transition-colors" className="w-full flex items-center gap-2.5 bg-[#1a1d2e] border border-[#ffffff0f] hover:border-[#f97316]/30 rounded-xl px-4 py-3 text-sm text-[#9ca3af] hover:text-[#e5e7eb] transition-all duration-200"
> >
<Briefcase className="h-4 w-4 text-[#1BB0CE] flex-none" /> <Briefcase className="h-4 w-4 text-[#f97316] flex-none" />
<span className="flex-1 text-left truncate"> <span className="flex-1 text-left truncate">
{selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"} {selected ? selected.job_title : loading ? "Loading jobs..." : "Select a job category"}
</span> </span>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronDown className="h-3.5 w-3.5 text-[#6a6a75]" />} {loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-[#f97316]" /> : <ChevronDown className={`h-3.5 w-3.5 text-[#4b5563] transition-transform duration-200 ${open ? "rotate-180" : ""}`} />}
</button> </button>
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute top-full left-0 right-0 mt-1 z-20 bg-[#15151e] border border-[#2a2a35] rounded-lg shadow-xl max-h-60 overflow-y-auto"> <div className="absolute top-full left-0 right-0 mt-1.5 z-20 bg-[#1a1d2e] border border-[#ffffff0f] rounded-xl shadow-xl shadow-black/40 max-h-60 overflow-y-auto">
{jobs.map((job, i) => ( {jobs.map((job, i) => (
<button <button
key={i} key={i}
type="button" type="button"
onClick={() => handleSelect(job)} onClick={() => handleSelect(job)}
className="w-full text-left px-3 py-2.5 text-sm text-[#c8c8d0] hover:bg-[#1a1a24] hover:text-[#e8e8ef] transition-colors border-b border-[#1a1a24] last:border-0" className="w-full text-left px-4 py-3 text-sm text-[#9ca3af] hover:bg-[#1f2437] hover:text-[#e5e7eb] transition-all duration-150 border-b border-[#ffffff08] last:border-0 border-l-2 border-l-transparent hover:border-l-[#f97316]/40"
> >
<div className="font-medium">{job.job_title}</div> <div className="font-medium">{job.job_title}</div>
<div className="text-xs text-[#6a6a75] mt-0.5">{job.industry} {job.description}</div> <div className="text-xs text-[#4b5563] mt-0.5">{job.industry} &mdash; {job.description}</div>
</button> </button>
))} ))}
{jobs.length === 0 && !loading && ( {jobs.length === 0 && !loading && (
<div className="px-3 py-4 text-xs text-[#6a6a75] text-center">No job categories loaded</div> <div className="px-4 py-4 text-xs text-[#4b5563] text-center">No job categories loaded</div>
)} )}
</div> </div>
</> </>
+15 -7
View File
@@ -39,8 +39,9 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const [gifResults, setGifResults] = useState<any[]>([]) const [gifResults, setGifResults] = useState<any[]>([])
const [gifLoading, setGifLoading] = useState(false) const [gifLoading, setGifLoading] = useState(false)
const [gifError, setGifError] = useState("") const [gifError, setGifError] = useState("")
const [gifNoKey, setGifNoKey] = useState(false) const [gifUnavailable, setGifUnavailable] = useState(false)
const [gifNext, setGifNext] = useState("") const [gifNext, setGifNext] = useState("")
const gifCache = useRef<Map<string, { results: any[]; next: string }>>(new Map())
const [recentGifs, setRecentGifs] = useState<string[]>([]) const [recentGifs, setRecentGifs] = useState<string[]>([])
const [recentStickers, setRecentStickers] = useState<string[]>([]) const [recentStickers, setRecentStickers] = useState<string[]>([])
const [stickerPacks] = useState<StickerPack[]>(getStickerPacks) const [stickerPacks] = useState<StickerPack[]>(getStickerPacks)
@@ -60,6 +61,13 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, []) useEffect(() => { setRecentStickers(getRecent(RECENT_STICKERS_KEY)) }, [])
const fetchGifs = useCallback(async (query: string, pos = "") => { const fetchGifs = useCallback(async (query: string, pos = "") => {
const cacheKey = `${query}::${pos}`
const cached = gifCache.current.get(cacheKey)
if (cached && !pos) {
setGifResults(cached.results)
setGifNext(cached.next)
return
}
setGifLoading(true) setGifLoading(true)
setGifError("") setGifError("")
try { try {
@@ -68,17 +76,18 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
const res = await fetch(`/api/gifs?${params}`) const res = await fetch(`/api/gifs?${params}`)
const data = await res.json() const data = await res.json()
if (data.noKey) { if (data.noKey) {
setGifNoKey(true) setGifUnavailable(true)
setGifResults([]) setGifResults([])
} else if (data.results) { } else if (data.results) {
setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results)) setGifResults((prev) => (pos ? [...prev, ...data.results] : data.results))
setGifNext(data.next || "") setGifNext(data.next || "")
setGifNoKey(false) setGifUnavailable(false)
if (!pos) gifCache.current.set(cacheKey, { results: data.results, next: data.next || "" })
} else { } else {
setGifError("No results found") setGifError("No results found")
} }
} catch { } catch {
setGifError("Failed to load GIFs") setGifError("Could not load GIFs. Check your connection.")
} }
setGifLoading(false) setGifLoading(false)
}, []) }, [])
@@ -196,11 +205,10 @@ export default function MediaPicker({ onEmojiSelect, onMediaSelect, onClose, the
<div className="flex items-center justify-center h-48"> <div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : gifNoKey ? ( ) : gifUnavailable ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
<Image className="h-8 w-8 opacity-40" /> <Image className="h-8 w-8 opacity-40" />
<span>GIF search requires a Tenor API key</span> <span>GIFs are temporarily unavailable.</span>
<span className="text-xs opacity-60">Set TENOR_API_KEY in .env.local</span>
</div> </div>
) : gifError ? ( ) : gifError ? (
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2"> <div className="flex flex-col items-center justify-center h-48 text-muted-foreground text-sm gap-2">
+34 -34
View File
@@ -162,11 +162,11 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center" className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
> >
<div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#E0E0E0] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative"> <div className="bg-white dark:bg-[#141414] rounded-2xl p-6 w-full max-w-md border border-[#D4D8CC] dark:border-[#CC0000]/20 shadow-[0_8px_40px_rgba(0,0,0,0.18)] relative">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="absolute top-4 right-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors" className="absolute top-4 right-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -174,27 +174,27 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
{callLink ? ( {callLink ? (
shareSent ? ( shareSent ? (
<> <>
<p className="text-sm text-[#00AA00] font-semibold text-center mb-3">Call link copied.</p> <p className="text-sm text-[#4A9078] font-semibold text-center mb-3">Call link copied.</p>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Waiting for participant</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Waiting for participant</h2>
<p className="text-[#888888] text-sm mt-1 mb-4"> <p className="text-[#8A9078] text-sm mt-1 mb-4">
The recipient must receive and open the call link. The recipient must receive and open the call link.
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white"> <div className="bg-card dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-foreground dark:text-white">
{callLink} {callLink}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={copyLink} onClick={copyLink}
className="flex-1 flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex-1 flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Copy URL Copy URL
</button> </button>
<button <button
onClick={() => { setShareSent(false); setShareError(null) }} onClick={() => { setShareSent(false); setShareError(null) }}
className="flex-1 flex items-center justify-center gap-2 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex-1 flex items-center justify-center gap-2 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
WhatsApp WhatsApp
@@ -205,24 +205,24 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
href={callLink} href={callLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="block w-full mt-4 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200" className="block w-full mt-4 bg-muted-foreground hover:bg-muted-foreground/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 text-center transition-all duration-200"
> >
Open Call Open Call
</a> </a>
</> </>
) : ( ) : (
<> <>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Choose how to notify this person</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Choose how to notify this person</h2>
<p className="text-[#888888] text-sm mt-1 mb-4"> <p className="text-[#8A9078] text-sm mt-1 mb-4">
Select a method to send the call link Select a method to send the call link
</p> </p>
<div className="bg-[#F5F5F5] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#111111] dark:text-white"> <div className="bg-[#FAFAF6] dark:bg-[#1A1A1A] rounded-xl p-3 mb-4 break-all text-xs text-[#2D3020] dark:text-white">
{callLink} {callLink}
</div> </div>
{showWaUrl && ( {showWaUrl && (
<div className="bg-[#1A1A1A] dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#00AA00] font-mono"> <div className="bg-card dark:bg-[#000000] rounded-xl p-2 mb-3 break-all text-[10px] text-[#4A9078] font-mono">
{showWaUrl} {showWaUrl}
</div> </div>
)} )}
@@ -237,7 +237,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</button> </button>
<button <button
onClick={copyLink} onClick={copyLink}
className="flex items-center justify-center gap-2 bg-[#444444] hover:bg-[#555555] dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200" className="flex items-center justify-center gap-2 bg-[#8A9078] hover:bg-[#8A9078]/80 dark:bg-[#333333] dark:hover:bg-[#444444] text-white font-semibold text-sm rounded-xl py-2.5 transition-all duration-200"
> >
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
Copy URL Copy URL
@@ -245,16 +245,16 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div> </div>
{shareError && ( {shareError && (
<p className="text-[#CC0000] text-xs text-center">{shareError}</p> <p className="text-[#C84B4B] text-xs text-center">{shareError}</p>
)} )}
</> </>
) )
) : ( ) : (
<> <>
<h2 className="font-bold text-lg text-[#111111] dark:text-white">Start a Call</h2> <h2 className="font-bold text-lg text-[#2D3020] dark:text-white">Start a Call</h2>
<p className="text-[#888888] text-sm mt-1 mb-5">Search contacts or dial a number</p> <p className="text-[#8A9078] text-sm mt-1 mb-5">Search contacts or dial a number</p>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2"> <p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
CONTACTS CONTACTS
</p> </p>
@@ -262,25 +262,25 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search contacts..." placeholder="Search contacts..."
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full mb-3 outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/> />
<div className="max-h-[200px] overflow-y-auto space-y-1 mb-4"> <div className="max-h-[200px] overflow-y-auto space-y-1 mb-4">
{contactsLoading && ( {contactsLoading && (
<p className="text-[#AAAAAA] text-sm text-center py-4 animate-pulse">Loading contacts...</p> <p className="text-[#8A9078] text-sm text-center py-4 animate-pulse">Loading contacts...</p>
)} )}
{contactsError && ( {contactsError && (
<p className="text-[#CC0000] text-sm text-center py-4">Could not load contacts</p> <p className="text-[#C84B4B] text-sm text-center py-4">Could not load contacts</p>
)} )}
{!contactsLoading && !contactsError && filteredContacts.length === 0 && ( {!contactsLoading && !contactsError && filteredContacts.length === 0 && (
<p className="text-[#AAAAAA] text-sm text-center py-4">No contacts found</p> <p className="text-[#8A9078] text-sm text-center py-4">No contacts found</p>
)} )}
{!contactsLoading && !contactsError && filteredContacts.map((contact) => ( {!contactsLoading && !contactsError && filteredContacts.map((contact) => (
<div <div
key={contact.id} key={contact.id}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#F5F5F5] dark:hover:bg-[#1A1A1A]" className="flex items-center gap-3 px-3 py-2.5 rounded-xl cursor-pointer transition-all duration-150 hover:bg-[#FAFAF6] dark:hover:bg-[#1A1A1A]"
> >
<div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FFF0F0] dark:bg-[#CC0000]/15 text-[#CC0000] dark:text-[#FF4444]"> <div className="w-9 h-9 rounded-full flex items-center justify-center text-sm font-bold shrink-0 bg-[#FAEAEA] dark:bg-[#CC0000]/15 text-[#C84B4B] dark:text-[#FF4444]">
{contact.avatar_url ? ( {contact.avatar_url ? (
<img <img
src={contact.avatar_url} src={contact.avatar_url}
@@ -292,13 +292,13 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
)} )}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-[#111111] dark:text-white truncate">{contact.name}</p> <p className="text-sm font-medium text-[#2D3020] dark:text-white truncate">{contact.name}</p>
<p className="text-xs text-[#888888] dark:[#666666] truncate">{contact.phone}</p> <p className="text-xs text-[#8A9078] dark:[#666666] truncate">{contact.phone}</p>
</div> </div>
<button <button
type="button" type="button"
onClick={() => handleCall(contact.phone)} onClick={() => handleCall(contact.phone)}
className="shrink-0 text-[#CC0000] dark:text-[#FF4444] hover:opacity-80 transition-opacity" className="shrink-0 text-[#C84B4B] dark:text-[#FF4444] hover:opacity-80 transition-opacity"
> >
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
</button> </button>
@@ -307,12 +307,12 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
</div> </div>
<div className="flex items-center gap-3 my-4"> <div className="flex items-center gap-3 my-4">
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" /> <hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
<span className="text-xs text-[#AAAAAA] dark:text-[#555555]">OR</span> <span className="text-xs text-[#8A9078] dark:text-[#555555]">OR</span>
<hr className="flex-1 border-t border-[#E0E0E0] dark:border-[#222222]" /> <hr className="flex-1 border-t border-[#D4D8CC] dark:border-[#222222]" />
</div> </div>
<p className="text-[10px] font-bold uppercase tracking-widest text-[#888888] dark:text-[#666666] mb-2"> <p className="text-[10px] font-bold uppercase tracking-widest text-[#8A9078] dark:text-[#666666] mb-2">
DIAL A NUMBER DIAL A NUMBER
</p> </p>
@@ -321,10 +321,10 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
value={phoneNumber} value={phoneNumber}
onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }} onChange={(e) => { setPhoneNumber(e.target.value); setPhoneError(false) }}
placeholder="+27 000 000 0000" placeholder="+27 000 000 0000"
className="bg-[#F5F5F5] dark:bg-[#1A1A1A] border border-[#E0E0E0] dark:border-[#333333] text-[#111111] dark:text-white placeholder-[#AAAAAA] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#CC0000] dark:focus:border-[#FF4444]" className="bg-[#FAFAF6] dark:bg-[#1A1A1A] border border-[#D4D8CC] dark:border-[#333333] text-[#2D3020] dark:text-white placeholder-[#8A9078] dark:placeholder-[#555555] rounded-xl px-4 py-2.5 text-sm w-full outline-none transition-colors focus:border-[#C84B4B] dark:focus:border-[#FF4444]"
/> />
{phoneError && ( {phoneError && (
<p className="text-[#CC0000] text-xs mt-1">Please enter a phone number</p> <p className="text-[#C84B4B] text-xs mt-1">Please enter a phone number</p>
)} )}
<button <button
@@ -337,7 +337,7 @@ export default function VoiceCallModal({ open, onClose }: VoiceCallModalProps) {
setPhoneError(false) setPhoneError(false)
handleCall(trimmed) handleCall(trimmed)
}} }}
className="w-full mt-3 bg-[#CC0000] hover:bg-[#990000] dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200" className="w-full mt-3 bg-[#C84B4B] hover:bg-[#C84B4B]/80 dark:bg-[#FF1111] dark:hover:bg-[#CC0000] text-white font-semibold text-sm rounded-xl py-2.5 flex items-center justify-center gap-2 transition-all duration-200"
> >
<Phone className="h-4 w-4" /> <Phone className="h-4 w-4" />
Call Now Call Now
@@ -21,14 +21,14 @@ export function CrossedLightsabers() {
<circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" /> <circle cx="100" cy="73" r="24" fill="url(#purpleGlow)" className="animate-pulse-intersection" />
<g className="animate-pulse-red"> <g className="animate-pulse-red">
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-outer" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-outer" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#dc2626] dark:stroke-[#ef4444] saber-mid" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#C84B4B] dark:stroke-[#ef4444] saber-mid" />
<line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" /> <line x1="35" y1="145" x2="125" y2="45" strokeLinecap="round" className="stroke-[#f87171] dark:stroke-[#fca5a5] saber-core" />
</g> </g>
<g className="animate-pulse-blue"> <g className="animate-pulse-blue">
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-outer" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.12" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-outer" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#1d4ed8] dark:stroke-[#3b82f6] saber-blue-mid" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" opacity="0.25" className="stroke-[#5A8FC4] dark:stroke-[#3b82f6] saber-blue-mid" />
<line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" /> <line x1="165" y1="145" x2="75" y2="45" strokeLinecap="round" className="stroke-[#60a5fa] dark:stroke-[#93c5fd] saber-blue-core" />
</g> </g>
@@ -98,7 +98,7 @@ export function LeadStatusChart({ data }: LeadStatusChartProps) {
key={i} key={i}
d={arcPath(160, 160, oR, iR, s.start, s.end)} d={arcPath(160, 160, oR, iR, s.start, s.end)}
fill={`url(#chart-grad-${i})`} fill={`url(#chart-grad-${i})`}
stroke="#d4d4d4" stroke="#000000"
strokeWidth="1" strokeWidth="1"
opacity={hov !== null && !isH ? 0.3 : 1} opacity={hov !== null && !isH ? 0.3 : 1}
style={{ style={{
@@ -16,8 +16,8 @@ interface LeadsPerMonthChartProps {
data: IntervalData[] data: IntervalData[]
} }
const NEW_LEADS = "#CC0000" const NEW_LEADS = "#C84B4B"
const CLOSED = "#0033CC" const CLOSED = "#5A8FC4"
export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) { export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProps) {
const [year, setYear] = useState(new Date().getFullYear()) const [year, setYear] = useState(new Date().getFullYear())
@@ -153,11 +153,11 @@ export function LeadsPerMonthChart({ data: initialData }: LeadsPerMonthChartProp
> >
<defs> <defs>
<linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="newLeadsGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#FF1111" /> <stop offset="0%" stopColor="#C84B4B" />
<stop offset="100%" stopColor={NEW_LEADS} /> <stop offset="100%" stopColor={NEW_LEADS} />
</linearGradient> </linearGradient>
<linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="closedGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#1144FF" /> <stop offset="0%" stopColor="#5A8FC4" />
<stop offset="100%" stopColor={CLOSED} /> <stop offset="100%" stopColor={CLOSED} />
</linearGradient> </linearGradient>
<filter id="shadowNew"> <filter id="shadowNew">
@@ -9,11 +9,11 @@ import { Lead } from "@/types"
import { ArrowRight } from "lucide-react" import { ArrowRight } from "lucide-react"
const statusStyles: Record<string, string> = { 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", open: "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20",
contacted: "bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20", contacted: "bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20",
pending: "bg-blue-900/10 text-blue-900 dark:text-blue-300 border-blue-900/20", pending: "bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20",
closed: "bg-blue-400/10 text-blue-600 dark:text-blue-300 border-blue-400/20", closed: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20",
ignored: "bg-black/10 text-black dark:text-gray-300 border-black/20 dark:border-black/40", ignored: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20",
} }
interface RecentLeadsTableProps { interface RecentLeadsTableProps {
@@ -23,10 +23,10 @@ export function StatCardSkeleton() {
return ( return (
<div className="col-span-full flex flex-col items-center justify-center py-20"> <div className="col-span-full flex flex-col items-center justify-center py-20">
<p className="text-[#444444] dark:text-[#AAAAAA] text-sm"> <p className="text-[#8A9078] dark:text-[#AAAAAA] text-sm">
Loading your data... Loading your data...
</p> </p>
<div className="w-48 h-1 rounded-full overflow-hidden bg-[#E0E0E0] dark:bg-[#222222] mt-4"> <div className="w-48 h-1 rounded-full overflow-hidden bg-[#D4D8CC] dark:bg-[#222222] mt-4">
<div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" /> <div className="w-full h-full rounded-full bg-sidebar animate-[loading_1.5s_ease-in-out_infinite]" />
</div> </div>
</div> </div>
+27 -23
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { motion } from "framer-motion" import { motion } from "framer-motion"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { LucideIcon } from "lucide-react" import { LucideIcon } from "lucide-react"
@@ -19,12 +20,12 @@ interface StatCardProps {
} }
const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = { const cardColors: Record<string, { bg: string; text: string; glow: string; accent: string }> = {
"Total Leads": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Total Leads": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Open Leads": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Open Leads": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Contacted": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Contacted": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Pending": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Pending": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
"Closed": { bg: "bg-[#CC0000]/10", text: "text-[#CC0000] dark:text-[#FF1111]", glow: "via-[rgba(204,0,0,0.25)]", accent: "#CC0000" }, "Closed": { bg: "bg-[#C84B4B]/10", text: "text-[#C84B4B] dark:text-[#FF1111]", glow: "via-[rgba(200,75,75,0.25)]", accent: "#C84B4B" },
"Conversion Rate": { bg: "bg-[#0033CC]/10", text: "text-[#0033CC] dark:text-[#1144FF]", glow: "via-[rgba(0,51,204,0.25)]", accent: "#0033CC" }, "Conversion Rate": { bg: "bg-[#5A8FC4]/10", text: "text-[#5A8FC4] dark:text-[#1144FF]", glow: "via-[rgba(90,143,196,0.25)]", accent: "#5A8FC4" },
} }
function computeGoal(max: number): number { function computeGoal(max: number): number {
@@ -50,6 +51,7 @@ function smoothPath(points: { x: number; y: number }[]): string {
} }
export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) { export function StatCard({ title, value, icon: Icon, description, index = 0, trend, sparklineField, monthlyBreakdown, conversionRate }: StatCardProps) {
const { websiteTheme } = useWebsiteTheme()
const color = cardColors[title] ?? cardColors["Total Leads"] const color = cardColors[title] ?? cardColors["Total Leads"]
const isNumeric = typeof value === "number" const isNumeric = typeof value === "number"
@@ -108,8 +110,8 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
const sparkColor = color.accent const sparkColor = color.accent
const isRed = index % 2 === 0 const isRed = index % 2 === 0
const stripColor = isRed ? "from-[#CC0000] via-[#FF1111] to-[#CC0000]" : "from-[#0033CC] via-[#1144FF] to-[#0033CC]" const stripColor = isRed ? "from-[#C84B4B] via-[#C84B4B] to-[#C84B4B]" : "from-[#5A8FC4] via-[#5A8FC4] to-[#5A8FC4]"
const stripGlow = isRed ? "shadow-[#CC0000]/20" : "shadow-[#0033CC]/20" const stripGlow = isRed ? "shadow-[#C84B4B]/20" : "shadow-[#5A8FC4]/20"
return ( return (
<motion.div <motion.div
@@ -119,28 +121,30 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
className="relative" className="relative"
> >
<Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden"> <Card className="group h-full hover:shadow-xl transition-all duration-200 relative z-10 overflow-hidden">
{/* Red/Blue gradient top strip */} {websiteTheme === "spidey" && (
<div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} /> <div className={`h-1 w-full bg-gradient-to-r ${stripColor} ${stripGlow} shadow-sm`} />
)}
{/* Action lines on hover */} {websiteTheme === "spidey" && (
<div <div
className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0" className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"
style={{ 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")`, 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", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
}} }}
/> />
)}
<CardContent className="p-6 flex flex-col relative z-[1]"> <CardContent className="p-6 flex flex-col relative z-[1]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">{title}</p> <p className="text-sm font-medium text-muted-foreground">{title}</p>
<p className="text-3xl font-bold tracking-tight text-[#111111] dark:text-white"> <p className="text-3xl font-bold tracking-tight text-[#2D3020] dark:text-white">
{isNumeric ? display : value} {isNumeric ? display : value}
</p> </p>
{description && ( {description && (
<p className="text-xs text-[#888888] dark:text-[#666666]">{description}</p> <p className="text-xs text-[#8A9078] dark:text-[#666666]">{description}</p>
)} )}
{trend && ( {trend && (
<span className={cn( <span className={cn(
@@ -196,9 +200,9 @@ export function StatCard({ title, value, icon: Icon, description, index = 0, tre
{title === "Conversion Rate" && conversionRate !== undefined && ( {title === "Conversion Rate" && conversionRate !== undefined && (
<div className="mt-3"> <div className="mt-3">
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden"> <div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div className="h-full rounded-full bg-gradient-to-r from-[#CC0000] to-[#0033CC]" style={{ width: `${Math.min(conversionRate, 100)}%` }} /> <div className="h-full rounded-full bg-gradient-to-r from-[#C84B4B] to-[#5A8FC4]" style={{ width: `${Math.min(conversionRate, 100)}%` }} />
</div> </div>
<p className="text-xs text-[#888888] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p> <p className="text-xs text-[#8A9078] dark:text-[#666666] mt-1">{conversionRate}% conversion rate</p>
</div> </div>
)} )}
</CardContent> </CardContent>
+91 -40
View File
@@ -2,6 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
import { usePathname } from "next/navigation" import { usePathname } from "next/navigation"
import { useWebsiteTheme } from "@/providers/website-theme-provider"
import { motion, AnimatePresence } from "framer-motion" import { motion, AnimatePresence } from "framer-motion"
import { Sidebar } from "./sidebar" import { Sidebar } from "./sidebar"
import { Topbar } from "./topbar" import { Topbar } from "./topbar"
@@ -11,6 +12,7 @@ interface AppShellProps {
} }
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
const { websiteTheme } = useWebsiteTheme()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false) const [mobileOpen, setMobileOpen] = useState(false)
const pathname = usePathname() const pathname = usePathname()
@@ -33,51 +35,100 @@ export function AppShell({ children }: AppShellProps) {
} }
return ( return (
<div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden" <div className="min-h-screen bg-[#F8F8F8] dark:bg-[#0A0A0A] relative overflow-hidden"
style={{ style={websiteTheme === "spidey" ? {
backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)", backgroundImage: "radial-gradient(circle, #CC000010 1px, transparent 1px), radial-gradient(circle, #0033CC06 1px, transparent 1px)",
backgroundSize: "28px 28px, 14px 14px", backgroundSize: "28px 28px, 14px 14px",
backgroundPosition: "0 0, 7px 7px", backgroundPosition: "0 0, 7px 7px",
}} } : {}}
> >
{/* Spider-Man top gradient bar */} {/* Spider-Man decorations — Spidey theme only */}
<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" /> {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 */} {/* Cosmic decorations */}
<div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]"> {websiteTheme === "cosmic" && (
<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"/> <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" />
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/> <div className="hidden lg:block fixed top-0 left-0 pointer-events-none z-0 opacity-[0.12]">
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/> <svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="40" cy="30" r="2" fill="#00CCB3" opacity="0.8"/>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="85" cy="65" r="1.5" fill="#a855f7" opacity="0.6"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="25" cy="120" r="1" fill="#00CCB3" opacity="0.5"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="60" cy="170" r="2.5" fill="#a855f7" opacity="0.7"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="120" cy="25" r="1.5" fill="#00CCB3" opacity="0.6"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="160" cy="80" r="1" fill="#a855f7" opacity="0.4"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="140" cy="150" r="2" fill="#00CCB3" opacity="0.7"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="180" cy="40" r="1.5" fill="#a855f7" opacity="0.5"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="50" cy="90" r="3" fill="#00CCB3" opacity="0.08"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="130" cy="110" r="4" fill="#a855f7" opacity="0.06"/>
</svg> <path d="M40,30 Q60,50 85,65" stroke="#00CCB3" strokeWidth="0.4" opacity="0.25"/>
</div> <path d="M85,65 Q100,120 60,170" stroke="#a855f7" strokeWidth="0.4" opacity="0.2"/>
<div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.15] dark:opacity-[0.25]"> <path d="M25,120 Q80,140 140,150" stroke="#00CCB3" strokeWidth="0.4" opacity="0.2"/>
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}> <path d="M120,25 Q150,50 160,80" stroke="#a855f7" strokeWidth="0.3" opacity="0.2"/>
<line x1="0" y1="0" x2="180" y2="0" stroke="#CC0000" strokeWidth="0.8"/> </svg>
<line x1="0" y1="0" x2="180" y2="60" stroke="#CC0000" strokeWidth="0.8"/> </div>
<line x1="0" y1="0" x2="180" y2="120" stroke="#CC0000" strokeWidth="0.8"/> <div className="hidden lg:block fixed top-0 right-0 pointer-events-none z-0 opacity-[0.12]">
<line x1="0" y1="0" x2="180" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ transform: "scaleX(-1)" }}>
<line x1="0" y1="0" x2="120" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="40" cy="30" r="2" fill="#a855f7" opacity="0.8"/>
<line x1="0" y1="0" x2="60" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="85" cy="65" r="1.5" fill="#00CCB3" opacity="0.6"/>
<line x1="0" y1="0" x2="0" y2="180" stroke="#CC0000" strokeWidth="0.8"/> <circle cx="25" cy="120" r="1" fill="#a855f7" opacity="0.5"/>
<path d="M0,30 Q30,30 30,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="60" cy="170" r="2.5" fill="#00CCB3" opacity="0.7"/>
<path d="M0,60 Q60,60 60,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="120" cy="25" r="1.5" fill="#a855f7" opacity="0.6"/>
<path d="M0,90 Q90,90 90,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="160" cy="80" r="1" fill="#00CCB3" opacity="0.4"/>
<path d="M0,120 Q120,120 120,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="140" cy="150" r="2" fill="#a855f7" opacity="0.7"/>
<path d="M0,150 Q150,150 150,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="180" cy="40" r="1.5" fill="#00CCB3" opacity="0.5"/>
<path d="M0,180 Q180,180 180,0" stroke="#CC0000" strokeWidth="0.8" fill="none"/> <circle cx="50" cy="90" r="3" fill="#a855f7" opacity="0.08"/>
</svg> <circle cx="130" cy="110" r="4" fill="#00CCB3" opacity="0.06"/>
</div> <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 <Sidebar
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
+3 -3
View File
@@ -8,17 +8,17 @@ export function CrmIcon() {
viewBox="0 0 64 64" viewBox="0 0 64 64"
aria-hidden="true" aria-hidden="true"
> >
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}> <g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#e5e7eb]" style={{strokeWidth: "1.5px"}}>
<path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" /> <path className="dark:[stroke-linejoin:round]" d="m10.35 35.62s-.75.14-1-1.48-.58-8.61-.3-8.87a10 10 0 0 1 2.07-.63s0-7.54 2.67-11.86 5.4-6.09 10.65-7.56a29.38 29.38 0 0 1 15.18.3c4.41 1.18 8.7 4.62 9.89 8.8a49.27 49.27 0 0 1 1.49 9.55 9.53 9.53 0 0 1 2.52.9c.17.33.43 9.12 0 9.38a1.6 1.6 0 0 1 -.87.23s7.86 18 7.59 19-12.93 6.36-30.1 6.23-26.05-5.41-26.36-6.11 6.57-17.88 6.57-17.88z" />
</g> </g>
<g className="fill-[#777777] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}> <g className="fill-[#8A9078] dark:fill-white/[0.1] dark:stroke-[#d4d4d4]" style={{strokeWidth: "1px"}}>
<path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" /> <path d="m13.24 27.92s-.6-7.65 1.3-11.91a19.6 19.6 0 0 1 7.75-8.23 20.47 20.47 0 0 1 6.51-1.45c.05.16.67 10.23.66 11.61a27 27 0 0 1 -.57 4s-4-2.44-8.16-1.38a10.47 10.47 0 0 0 -7.49 7.36z" />
<path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" /> <path d="m30.11 22.18s.53-3.69.54-4.89-.8-10.82-.6-10.95a4.11 4.11 0 0 1 1.88 0c.07.16 1.07 11.66 1.28 13.22a9.33 9.33 0 0 1 .27 2.44 2.85 2.85 0 0 1 -3.37.18z" />
<path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" /> <path d="m34.8 21.44a76.48 76.48 0 0 1 -1.4-9.35c-.17-3.62-.51-5.65-.06-5.83s6.66.17 10.42 3.45 4.06 5.61 3.81 5.66-2.52-1-2.64-.77-.15.8.06.92 2.69 1.1 2.82 1.26a4.18 4.18 0 0 1 .28.95 16.94 16.94 0 0 0 -3-1.13c-.12.17-.23.92 0 1s3 1.3 3 1.3l.11.79s-2.83-1.18-3-1.14-.36.8-.15.92 3.16 1.26 3.25 1.43.41 1.36.41 1.36l-1.71-.54s.31.87.48 1 1.3.42 1.31.76a31.35 31.35 0 0 1 .2 3.33c-.12 0-2.2-6.45-5.21-7.09s-8.98 1.72-8.98 1.72z" />
<path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" /> <path d="m22.44 22.28c2.21 0 7.19 2.45 9.76 1.84s6.89-2.79 9.51-2.81 4.07 2.48 6.36 7.84 7.14 17.34 7 17.34-8.1-17.89-9.66-20.23-2.66-3-5.86-2.77-6.81 1-6.85 1.41 0 7.17.49 7.75 3.06.71 6.18.51 5-.33 5.77-.64a10.14 10.14 0 0 0 1.69-1l7.79 16.48a30.15 30.15 0 0 0 -2.94-2.56c-.21.05-.31.8-.19.88s3.67 3.33 4 3.7a12.43 12.43 0 0 1 1.61 2.63c-.12.17-.45.55-.66.47s-5-5.71-5.39-5.83-.45.6-.45.6 5.18 5.5 5.06 5.63a8.62 8.62 0 0 1 -1.15.57s-4.34-5-4.5-4.94-.69.6-.4.81a45.71 45.71 0 0 1 4.07 4.36 7 7 0 0 1 -1.2.4 46.49 46.49 0 0 0 -3.58-3.72c-.25 0-.61.39-.49.55s3.34 3.29 3.22 3.46a4 4 0 0 1 -1.16.37s-2.47-2.59-2.65-2.59-.74.31-.53.6 2.39 2.19 2.27 2.28a4.86 4.86 0 0 1 -1 .19c-.13 0-2-2-2.22-2s-.78.19-.65.48 1.83 1.79 1.63 1.92-5.17 1.66-15.76 1.51-14.84-1.85-14.84-1.85a46 46 0 0 0 -4.22-3.69c-.37 0-.78.6-.61.72a29.31 29.31 0 0 1 2.86 2.6c-.16 0-3.27-.92-4.32-1.1a7.6 7.6 0 0 1 -2.26-.61c0-.13 6-14.45 6.27-14.41s4.29 10.07 4.29 10.07a2.77 2.77 0 0 0 1.2 2.89 3.9 3.9 0 0 0 3.49-.25l15.51.08c.12 0 2.65 1.27 3.69-.68s.44-2.67.44-2.67 4-11.27 3.76-11.52a5.63 5.63 0 0 0 -1.76-.35s-2.75 10.32-3 10.33-1.26-.3-1.68-.34a3.18 3.18 0 0 0 -.7 0l-5.48-7.62s1.1-6-3.28-6-3.14 6-3.14 6-1-.06-2 1.51-3.39 6.29-3.39 6.29a3.77 3.77 0 0 0 -1.45.2c-.66.27-1.07.49-1.07.49s-3.54-8.29-3.46-8.46.82-2.15 1.2-2.16 2.93.39 3.93.41 1.16-.11 1.2-.28.27-.8.1-.8-5.11-.57-5.48-.57-1.07 2.14-1.36 2.23-1.32.25-1.2 0 2.16-5.13 2.29-5.1 7.53 1.07 9.73.64 2.76-1.11 3.07-2.16a31.45 31.45 0 0 0 .25-6.59 19.1 19.1 0 0 0 -6.2-1.31c-2.79-.09-4.67-.13-5.87 1.82s-10.79 27.71-10.96 27.76a3.64 3.64 0 0 1 -.92-.29s4.91-14 6.78-19.26 3.47-11.56 9.47-11.46z" />
<path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" /> <path d="m28.76 40.3a14.69 14.69 0 0 0 2.25 0l2.29-.09s-.39 9.67 0 9.75.67.27.66-.06-.19-7.75.06-7.79.75 1.52.75 1.52-.14 6.42 0 6.46.88.27.84.06 0-4.75 0-4.75l1.43 2.26-.79.06s-.08 2 .17 2a4.75 4.75 0 0 0 .71 0c.12 0 .11 1.16.11 1.16s-7.26-.19-9.8-.13-2.74.11-2.74.11a6.75 6.75 0 0 0 -.55-2l-.62-1.28s4.86-7.15 5.23-7.28z" />
</g> </g>
<g className="fill-[#333333] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}> <g className="fill-[#2D3020] dark:fill-white/[0.15] dark:stroke-[#d4d4d4]" style={{strokeWidth: "0.7px"}}>
<path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" /> <path d="m30.57 41.13c.28-.1.79-.06.84.1s.41 8.41.17 8.63-.88.14-.88 0-.25-8.69-.13-8.73z" />
<path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" /> <path d="m28.74 41.26c.21-.09.7-.19.79 0s.55 8.61.38 8.66-.87.18-.87 0-.3-8.66-.3-8.66z" />
<path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" /> <path d="m27 44.8c.06-.14.87-.4.87-.23a52.12 52.12 0 0 1 .21 5.25 3 3 0 0 1 -.79.06s-.38-4.88-.29-5.08z" />
+1 -1
View File
@@ -4,7 +4,7 @@ export function SidebarNameLogo() {
return ( return (
<svg <svg
viewBox="0 0 192.756 192.756" viewBox="0 0 192.756 192.756"
className="fill-[#333333] dark:fill-[#e5e7eb] w-full h-auto" className="fill-[#2D3020] dark:fill-[#e5e7eb] w-full h-auto"
aria-hidden="true" aria-hidden="true"
> >
<g fill-rule="evenodd" clip-rule="evenodd"> <g fill-rule="evenodd" clip-rule="evenodd">
+1 -1
View File
@@ -62,7 +62,7 @@ export function Sidebar({ collapsed, onToggle, mobileOpen, onMobileClose }: Side
> >
{/* Logo */} {/* Logo */}
<div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}> <div className={cn("flex h-16 items-center border-b border-sidebar-border px-4", collapsed ? "justify-center" : "justify-between")}>
<Link href="/" className="flex items-center gap-3 overflow-hidden"> <Link href="/dashboard" className="flex items-center gap-3 overflow-hidden">
{collapsed ? ( {collapsed ? (
<div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div> <div className="bc-logo" style={{padding: "8px 3px", fontSize: "16px"}}>B<span className="accent">C</span></div>
) : ( ) : (
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState, useEffect } from "react" import { useState, useEffect } from "react"
const RAM_LIMIT_MB = 8192 const RAM_LIMIT_MB = 16000
const CPU_LIMIT_PCT = 400 const CPU_LIMIT_PCT = 400
interface SystemMonitorProps { interface SystemMonitorProps {
+10 -38
View File
@@ -33,6 +33,7 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useWebsiteTheme } from "@/providers/website-theme-provider"; import { useWebsiteTheme } from "@/providers/website-theme-provider";
import { CaptainAmericaShield } from "@/components/shared/captain-america-shield";
interface TopbarProps { interface TopbarProps {
onMenuClick: () => void; onMenuClick: () => void;
@@ -72,40 +73,11 @@ export function Topbar({ onMenuClick }: TopbarProps) {
.toUpperCase(); .toUpperCase();
return ( return (
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-card dark:bg-[#141414] px-4 lg:px-6 relative overflow-hidden"> <header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-sidebar dark:bg-sidebar px-4 lg:px-6 relative overflow-hidden">
{/* Logo */} {/* Logo */}
<div className="flex items-center gap-2 mr-2 shrink-0"> <div className="flex items-center gap-2 mr-2 shrink-0">
{websiteTheme === "spidey" && ( {websiteTheme === "spidey" && <CaptainAmericaShield />}
<> <span className="text-primary font-bold text-sm tracking-wide">
<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">
CRM CRM
</span> </span>
</div> </div>
@@ -122,10 +94,10 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* Search */} {/* Search */}
<div className="relative hidden flex-1 sm:block md:w-80"> <div className="relative hidden flex-1 sm:block md:w-80">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#888888] dark:text-[#666666]" /> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground dark:text-[#666666]" />
<Input <Input
placeholder="Search leads, companies..." placeholder="Search leads, companies..."
className="h-9 w-full max-w-sm pl-9 bg-[#F0F0F0] dark:bg-[#1E1E1E] border-[#E0E0E0] dark:border-[#222222] text-[#111111] dark:text-white" className="h-9 w-full max-w-sm pl-9 bg-muted dark:bg-[#1E1E1E] border-border dark:border-[#222222] text-foreground dark:text-white"
/> />
</div> </div>
@@ -145,7 +117,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="text-muted-foreground" className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
> >
{mounted ? ( {mounted ? (
theme === "dark" ? ( theme === "dark" ? (
@@ -163,7 +135,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setBugModalOpen(true)} onClick={() => setBugModalOpen(true)}
className="text-muted-foreground" className="text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
title="Report a Bug" title="Report a Bug"
> >
<Bug className="h-5 w-5" /> <Bug className="h-5 w-5" />
@@ -175,7 +147,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative text-muted-foreground" className="relative text-muted-foreground hover:bg-[#8b949e]/10 hover:text-[#8b949e]"
> >
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground"> <span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
@@ -245,7 +217,7 @@ export function Topbar({ onMenuClick }: TopbarProps) {
{/* User profile */} {/* User profile */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3"> <Button variant="ghost" className="relative h-9 gap-2 pl-2 pr-3 hover:bg-[#8b949e]/10 hover:text-[#8b949e]">
<Avatar className="h-7 w-7"> <Avatar className="h-7 w-7">
<AvatarImage src={user.avatar} /> <AvatarImage src={user.avatar} />
<AvatarFallback>{initials}</AvatarFallback> <AvatarFallback>{initials}</AvatarFallback>
+10 -10
View File
@@ -7,23 +7,23 @@ import { cn } from "@/lib/utils"
const statusConfig: Record<LeadStatus, { label: string; class: string }> = { const statusConfig: Record<LeadStatus, { label: string; class: string }> = {
open: { open: {
label: "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: { contacted: {
label: "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: { pending: {
label: "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: { closed: {
label: "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: { ignored: {
label: "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 ( return (
<Badge variant="outline" className={cn(config.class, className)}> <Badge variant="outline" className={cn(config.class, className)}>
<span className={cn("mr-1.5 h-1.5 w-1.5 rounded-full", { <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-blue-500": status === "open",
"bg-red-500": status === "contacted", "bg-amber-500": status === "contacted",
"bg-blue-900": status === "pending", "bg-purple-500": status === "pending",
"bg-blue-400": status === "closed", "bg-emerald-500": status === "closed",
"bg-black": status === "ignored", "bg-zinc-500": status === "ignored",
})} /> })} />
{config.label} {config.label}
</Badge> </Badge>
+4 -1
View File
@@ -6,7 +6,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Sun, Moon, Monitor, Shield } from "lucide-react" import { Sun, Moon, Monitor, Palette, Shield } from "lucide-react"
import { useWebsiteTheme } from "@/providers/website-theme-provider" import { useWebsiteTheme } from "@/providers/website-theme-provider"
const COLOR_THEME_KEY = "crm-color-theme" const COLOR_THEME_KEY = "crm-color-theme"
@@ -31,7 +31,10 @@ const themeOptions = [
] ]
const backgroundOptions = [ 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: "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 { function getStoredColorTheme(): string {
+19 -19
View File
@@ -75,7 +75,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
> >
<button <button
onClick={handleClose} onClick={handleClose}
className="absolute right-4 top-4 text-[#888888] hover:text-[#111111] dark:hover:text-white transition-colors" className="absolute right-4 top-4 text-[#8A9078] hover:text-[#2D3020] dark:hover:text-white transition-colors"
> >
<X className="h-5 w-5" /> <X className="h-5 w-5" />
</button> </button>
@@ -83,15 +83,15 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
{submitted ? ( {submitted ? (
<div className="flex flex-col items-center py-8 text-center"> <div className="flex flex-col items-center py-8 text-center">
<CheckCircle className="h-12 w-12 text-emerald-500 mb-4" /> <CheckCircle className="h-12 w-12 text-emerald-500 mb-4" />
<h3 className="text-lg font-semibold text-[#111111] dark:text-white mb-2"> <h3 className="text-lg font-semibold text-[#2D3020] dark:text-white mb-2">
Bug Report Submitted Bug Report Submitted
</h3> </h3>
<p className="text-sm text-[#666666] dark:text-[#AAAAAA]"> <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA]">
Thank you for your report. The development team will investigate. Thank you for your report. The development team will investigate.
</p> </p>
<button <button
onClick={handleClose} onClick={handleClose}
className="mt-6 rounded-lg bg-[#CC0000] px-6 py-2 text-sm font-medium text-white hover:bg-[#AA0000] transition-colors" className="mt-6 rounded-lg bg-[#C84B4B] px-6 py-2 text-sm font-medium text-white hover:bg-[#C84B4B]/80 transition-colors"
> >
Done Done
</button> </button>
@@ -99,14 +99,14 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
) : ( ) : (
<> <>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-red-100 dark:bg-red-900/30"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#FAEAEA] dark:bg-red-900/30">
<Bug className="h-5 w-5 text-[#CC0000]" /> <Bug className="h-5 w-5 text-[#C84B4B]" />
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-[#111111] dark:text-white"> <h3 className="text-lg font-semibold text-[#2D3020] dark:text-white">
Report a Bug Report a Bug
</h3> </h3>
<p className="text-xs text-[#666666] dark:text-[#AAAAAA]"> <p className="text-xs text-[#8A9078] dark:text-[#AAAAAA]">
Help us improve the system Help us improve the system
</p> </p>
</div> </div>
@@ -120,8 +120,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Title <span className="text-[#CC0000]">*</span> Title <span className="text-[#C84B4B]">*</span>
</label> </label>
<input <input
type="text" type="text"
@@ -129,13 +129,13 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="Brief description of the issue" placeholder="Brief description of the issue"
required required
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Description <span className="text-[#CC0000]">*</span> Description <span className="text-[#C84B4B]">*</span>
</label> </label>
<textarea <textarea
value={description} value={description}
@@ -143,18 +143,18 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
placeholder="What happened? What did you expect?" placeholder="What happened? What did you expect?"
rows={4} rows={4}
required required
className="w-full resize-none rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white placeholder-[#999999] focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full resize-none rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white placeholder-[#8A9078] focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
/> />
</div> </div>
<div> <div>
<label className="mb-1.5 block text-sm font-medium text-[#444444] dark:text-[#CCCCCC]"> <label className="mb-1.5 block text-sm font-medium text-[#8A9078] dark:text-[#CCCCCC]">
Severity Severity
</label> </label>
<select <select
value={severity} value={severity}
onChange={(e) => setSeverity(e.target.value)} onChange={(e) => setSeverity(e.target.value)}
className="w-full rounded-lg border border-[#E0E0E0] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#111111] dark:text-white focus:border-[#CC0000] focus:outline-none focus:ring-1 focus:ring-[#CC0000]" className="w-full rounded-lg border border-[#D4D8CC] dark:border-[#333333] bg-white dark:bg-[#1E1E1E] px-3 py-2 text-sm text-[#2D3020] dark:text-white focus:border-[#C84B4B] focus:outline-none focus:ring-1 focus:ring-[#C84B4B]"
> >
<option value="low">Low Minor cosmetic issue</option> <option value="low">Low Minor cosmetic issue</option>
<option value="medium">Medium Affects functionality</option> <option value="medium">Medium Affects functionality</option>
@@ -163,8 +163,8 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
</select> </select>
</div> </div>
<div className="rounded-lg bg-[#F5F5F5] dark:bg-[#1A1A1A] px-3 py-2"> <div className="rounded-lg bg-[#FAFAF6] dark:bg-[#1A1A1A] px-3 py-2">
<p className="text-xs text-[#888888]"> <p className="text-xs text-[#8A9078]">
<span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""} <span className="font-medium">Page:</span> {typeof window !== "undefined" ? window.location.href : ""}
</p> </p>
</div> </div>
@@ -172,7 +172,7 @@ export function BugReportModal({ open, onClose }: BugReportModalProps) {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#CC0000] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#AA0000] disabled:opacity-50 transition-colors" className="flex w-full items-center justify-center gap-2 rounded-lg bg-[#C84B4B] px-4 py-2.5 text-sm font-medium text-white hover:bg-[#C84B4B]/80 disabled:opacity-50 transition-colors"
> >
{loading && <Loader2 className="h-4 w-4 animate-spin" />} {loading && <Loader2 className="h-4 w-4 animate-spin" />}
{loading ? "Submitting..." : "Submit Bug Report"} {loading ? "Submitting..." : "Submit Bug Report"}
@@ -8,24 +8,11 @@ export function CaptainAmericaShield() {
viewBox="0 0 100 100" viewBox="0 0 100 100"
aria-hidden="true" aria-hidden="true"
> >
<defs> <circle cx="50" cy="50" r="48" fill="#c62828" />
<clipPath id="shield-clip"> <circle cx="50" cy="50" r="36" fill="#efefef" />
<polygon points="50,95 5,60 5,30 50,5 95,30 95,60" /> <circle cx="50" cy="50" r="24" fill="#c62828" />
</clipPath> <circle cx="50" cy="50" r="14" fill="#1565c0" />
</defs> <polygon points="50,34 55,48 70,48 58,57 62,72 50,62 38,72 42,57 30,48 45,48" fill="#efefef" />
<g clipPath="url(#shield-clip)">
<circle cx="50" cy="45" r="45" fill="#c62828" />
<circle cx="50" cy="45" r="35" fill="#efefef" />
<circle cx="50" cy="45" r="25" fill="#c62828" />
<circle cx="50" cy="45" r="15" fill="#1565c0" />
<polygon points="50,32 56,47 72,48 60,58 64,75 50,64 36,75 40,58 28,48 44,47" fill="#efefef" />
</g>
<polygon
points="50,95 5,60 5,30 50,5 95,30 95,60"
fill="none"
stroke="#c62828"
strokeWidth="1.5"
/>
</svg> </svg>
) )
} }
+2 -2
View File
@@ -19,8 +19,8 @@ export function PageHeader({ title, description, children, className }: PageHead
className={cn("flex items-center justify-between pb-6", className)} className={cn("flex items-center justify-between pb-6", className)}
> >
<div> <div>
<h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#CC0000] dark:border-[#FF1111] pl-4 text-[#111111] dark:text-white">{title}</h1> <h1 className="text-2xl font-bold tracking-tight border-l-4 border-[#C84B4B] dark:border-[#FF1111] pl-4 text-[#2D3020] dark:text-white">{title}</h1>
{description && <p className="text-sm text-[#444444] dark:text-[#AAAAAA] mt-1">{description}</p>} {description && <p className="text-sm text-[#8A9078] dark:text-[#AAAAAA] mt-1">{description}</p>}
</div> </div>
{children && <div className="flex items-center gap-3">{children}</div>} {children && <div className="flex items-center gap-3">{children}</div>}
</motion.div> </motion.div>
+4 -4
View File
@@ -30,11 +30,11 @@ export function getDashboardStats(period: string): DashboardStats {
}) })
return [ return [
{ name: "Open", value: statusCounts.open, color: "#CC0000" }, { name: "Open", value: statusCounts.open, color: "#C84B4B" },
{ name: "Contacted", value: statusCounts.contacted, color: "#0033CC" }, { name: "Contacted", value: statusCounts.contacted, color: "#5A8FC4" },
{ name: "Pending", value: statusCounts.pending, color: "#FFFFFF" }, { name: "Pending", value: statusCounts.pending, color: "#FFFFFF" },
{ name: "Closed", value: statusCounts.closed, color: "#0033CC" }, { name: "Closed", value: statusCounts.closed, color: "#5A8FC4" },
{ name: "Ignored", value: statusCounts.ignored, color: "#CC0000" }, { name: "Ignored", value: statusCounts.ignored, color: "#C84B4B" },
] ]
} }
+13 -26
View File
@@ -3,13 +3,21 @@ import bcrypt from "bcryptjs";
import { query } from "@/lib/db"; import { query } from "@/lib/db";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
const RAW_SECRET = process.env.JWT_SECRET; function randomHex(length: number): string {
if (!RAW_SECRET) { const chars = "abcdef0123456789";
throw new Error("JWT_SECRET environment variable is required"); 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 JWT_SECRET = new TextEncoder().encode(RAW_SECRET);
const SESSION_COOKIE = "session"; export const SESSION_COOKIE = "session";
const MAX_FAILED_ATTEMPTS = 5; const MAX_FAILED_ATTEMPTS = 5;
const LOCKOUT_DURATION_MINUTES = 15; const LOCKOUT_DURATION_MINUTES = 15;
@@ -195,28 +203,7 @@ export function mapDbUserToSessionUser(
} }
export async function createSession(userId: string, role: string) { export async function createSession(userId: string, role: string) {
const token = await signToken({ userId, role }); return signToken({ userId, role });
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
});
return token;
}
export async function destroySession() {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/",
maxAge: 0,
});
} }
export async function encryptPassword(password: string): Promise<string> { export async function encryptPassword(password: string): Promise<string> {
+5 -5
View File
@@ -1,9 +1,9 @@
export const LEAD_STATUSES = { export const LEAD_STATUSES = {
open: { label: "Open", color: "bg-white" }, open: { label: "Open", color: "bg-blue-500" },
contacted: { label: "Contacted", color: "bg-red-500" }, contacted: { label: "Contacted", color: "bg-amber-500" },
pending: { label: "Pending", color: "bg-blue-900" }, pending: { label: "Pending", color: "bg-purple-500" },
closed: { label: "Closed", color: "bg-blue-400" }, closed: { label: "Closed", color: "bg-emerald-500" },
ignored: { label: "Ignored", color: "bg-black" }, ignored: { label: "Ignored", color: "bg-zinc-500" },
} as const } as const
export const LEAD_SOURCES = [ 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> <html><head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f4f5;padding:40px 20px"> <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="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> <h1 style="color:#fff;margin:0;font-size:20px">${heading}</h1>
</div> </div>
<div style="padding:32px"> <div style="padding:32px">
+1 -18
View File
@@ -1,12 +1,5 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server"
import type { NextRequest } 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 = [ const publicRoutes = [
"/login", "/login",
@@ -41,17 +34,7 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(loginUrl) return NextResponse.redirect(loginUrl)
} }
try { return NextResponse.next()
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)
}
} }
export const config = { 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 WEBSITE_THEME_KEY = "crm-website-theme"
const themeClasses: Record<string, string> = { const themeClasses: Record<string, string> = {
default: "theme-default",
spidey: "theme-spidey", spidey: "theme-spidey",
cosmic: "theme-cosmic",
cyber: "theme-cyber",
} }
interface WebsiteThemeContextValue { interface WebsiteThemeContextValue {
@@ -38,7 +41,7 @@ function storeTheme(theme: string) {
} }
export function WebsiteThemeProvider({ children }: { children: ReactNode }) { export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
const [websiteTheme, setWebsiteThemeState] = useState<string>("spidey") const [websiteTheme, setWebsiteThemeState] = useState<string>("default")
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -52,13 +55,13 @@ export function WebsiteThemeProvider({ children }: { children: ReactNode }) {
fetch("/api/settings/website-theme") fetch("/api/settings/website-theme")
.then((res) => (res.ok ? res.json() : null)) .then((res) => (res.ok ? res.json() : null))
.then((data) => { .then((data) => {
const theme = data?.websiteTheme || "spidey" const theme = data?.websiteTheme || "default"
setWebsiteThemeState(theme) setWebsiteThemeState(theme)
storeTheme(theme) storeTheme(theme)
applyWebsiteTheme(theme) applyWebsiteTheme(theme)
}) })
.catch(() => { .catch(() => {
applyWebsiteTheme("spidey") applyWebsiteTheme("default")
}) })
.finally(() => setLoading(false)) .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[]; 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 type EventStatus = "scheduled" | "completed" | "cancelled" | "rescheduled";
export interface CalendarEvent { export interface CalendarEvent {
id: string; id: string;
userId: string; userId: string;
participantId: string | null; participantId: string | null;
developerId: string | null;
leadId: string | null; leadId: string | null;
conversationId: string | null; conversationId: string | null;
title: string; title: string;
@@ -129,6 +130,10 @@ export interface CalendarEvent {
status: EventStatus; status: EventStatus;
creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; creator: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null; participant: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
developer: { id: string; name: string; email?: string; role?: string; avatar?: string } | null;
lead: { id: string; companyName: string; contactName: string } | null; lead: { id: string; companyName: string; contactName: string } | null;
clientName: string | null;
clientEmail: string | null;
clientPhone: string | null;
createdAt: string; createdAt: string;
} }
+6
View File
@@ -53,6 +53,12 @@ const config: Config = {
border: "hsl(var(--sidebar-border))", border: "hsl(var(--sidebar-border))",
ring: "hsl(var(--sidebar-ring))", 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: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",