BusyBee3333 4e6467ffb0 Add CRESync CRM application with Setup page
- Build complete Next.js CRM for commercial real estate
- Add authentication with JWT sessions and role-based access
- Add GoHighLevel API integration for contacts, conversations, opportunities
- Add AI-powered Control Center with tool calling
- Add Setup page with onboarding checklist (/setup)
- Add sidebar navigation with Setup menu item
- Fix type errors in onboarding API, GHL services, and control center tools
- Add Prisma schema with SQLite for local development
- Add UI components with clay morphism design system

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:30:55 -05:00

1666 lines
55 KiB
TypeScript

'use client';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { ClayCard } from '@/components/ClayCard';
import { api } from '@/lib/api/client';
import {
Search,
Plus,
MoreVertical,
Mail,
Phone,
Tag,
Trash2,
Edit,
User,
X,
Eye,
Calendar,
Loader2,
AlertCircle,
Users,
ChevronUp,
ChevronDown,
Filter,
MessageSquare,
Building,
Clock,
Activity
} from 'lucide-react';
// =============================================================================
// Types
// =============================================================================
interface Contact {
id: string;
firstName?: string;
lastName?: string;
name?: string;
email?: string;
phone?: string;
tags?: string[];
createdAt: string;
companyName?: string;
}
interface ContactFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
tags: string[];
companyName: string;
}
type SortField = 'name' | 'email' | 'phone' | 'createdAt';
type SortDirection = 'asc' | 'desc';
interface SortConfig {
field: SortField;
direction: SortDirection;
}
// =============================================================================
// Constants
// =============================================================================
const TAG_COLORS: Record<string, { bg: string; text: string; border: string }> = {
'Investor': { bg: 'bg-emerald-50', text: 'text-emerald-700', border: 'border-emerald-200' },
'Hot Lead': { bg: 'bg-red-50', text: 'text-red-700', border: 'border-red-200' },
'Buyer': { bg: 'bg-blue-50', text: 'text-blue-700', border: 'border-blue-200' },
'Developer': { bg: 'bg-purple-50', text: 'text-purple-700', border: 'border-purple-200' },
'VIP': { bg: 'bg-amber-50', text: 'text-amber-700', border: 'border-amber-200' },
'Tenant Rep': { bg: 'bg-cyan-50', text: 'text-cyan-700', border: 'border-cyan-200' },
'Institutional': { bg: 'bg-indigo-50', text: 'text-indigo-700', border: 'border-indigo-200' },
'Seller': { bg: 'bg-orange-50', text: 'text-orange-700', border: 'border-orange-200' },
'Landlord': { bg: 'bg-teal-50', text: 'text-teal-700', border: 'border-teal-200' },
'default': { bg: 'bg-slate-50', text: 'text-slate-700', border: 'border-slate-200' },
};
const FILTER_OPTIONS = [
{ value: 'all', label: 'All Contacts' },
{ value: 'recent', label: 'Added This Week' },
{ value: 'investor', label: 'Investors' },
{ value: 'buyer', label: 'Buyers' },
{ value: 'hot-lead', label: 'Hot Leads' },
{ value: 'vip', label: 'VIP Contacts' },
];
const FIELD_LIMITS = {
firstName: 50,
lastName: 50,
email: 100,
phone: 20,
companyName: 100,
};
// =============================================================================
// Mock Data (replace with real API calls)
// =============================================================================
const mockContacts: Contact[] = [
{
id: '1',
firstName: 'John',
lastName: 'Smith',
email: 'john.smith@example.com',
phone: '(555) 123-4567',
tags: ['Investor', 'Hot Lead'],
createdAt: '2024-01-15T10:30:00Z',
companyName: 'Smith Holdings LLC',
},
{
id: '2',
firstName: 'Sarah',
lastName: 'Johnson',
email: 'sarah.j@realestate.com',
phone: '(555) 987-6543',
tags: ['Buyer'],
createdAt: '2024-01-10T14:20:00Z',
companyName: 'Johnson Properties',
},
{
id: '3',
firstName: 'Michael',
lastName: 'Chen',
email: 'mchen@capitalgroup.com',
phone: '(555) 456-7890',
tags: ['Developer', 'VIP'],
createdAt: '2024-01-08T09:15:00Z',
companyName: 'Capital Development Group',
},
{
id: '4',
firstName: 'Emily',
lastName: 'Rodriguez',
email: 'emily.r@commercialre.com',
phone: '(555) 321-0987',
tags: ['Tenant Rep'],
createdAt: '2024-01-05T16:45:00Z',
companyName: 'Commercial RE Solutions',
},
{
id: '5',
firstName: 'David',
lastName: 'Thompson',
email: 'dthompson@ventures.io',
phone: '(555) 654-3210',
tags: ['Investor', 'Institutional'],
createdAt: '2024-01-03T11:00:00Z',
companyName: 'Thompson Ventures',
},
];
// =============================================================================
// Utility Functions
// =============================================================================
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
const getContactDisplayName = (contact: Contact): string => {
if (contact.name) return contact.name;
if (contact.firstName || contact.lastName) {
return `${contact.firstName || ''} ${contact.lastName || ''}`.trim();
}
return 'Unknown';
};
const getTagColor = (tag: string) => {
return TAG_COLORS[tag] || TAG_COLORS['default'];
};
const isWithinLastWeek = (dateString: string): boolean => {
const date = new Date(dateString);
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
return date >= weekAgo;
};
// =============================================================================
// Tag Chip Component
// =============================================================================
interface TagChipProps {
tag: string;
onRemove?: () => void;
size?: 'sm' | 'md';
}
const TagChip: React.FC<TagChipProps> = ({ tag, onRemove, size = 'sm' }) => {
const colors = getTagColor(tag);
const sizeClasses = size === 'sm' ? 'text-xs px-3.5 py-1.5' : 'text-sm px-3.5 py-1.5';
return (
<span
className={`inline-flex items-center gap-1 rounded-full font-medium border ${colors.bg} ${colors.text} ${colors.border} ${sizeClasses}`}
>
{tag}
{onRemove && (
<button
type="button"
onClick={onRemove}
className={`${colors.text} hover:opacity-70 transition-opacity ml-0.5`}
aria-label={`Remove ${tag} tag`}
>
<X size={size === 'sm' ? 12 : 14} />
</button>
)}
</span>
);
};
// =============================================================================
// Loading Skeleton Component
// =============================================================================
const TableSkeleton: React.FC = () => (
<div className="animate-pulse">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4 py-4 border-b border-slate-100">
<div className="w-10 h-10 bg-slate-200 rounded-full" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-slate-200 rounded w-1/4" />
<div className="h-3 bg-slate-100 rounded w-1/3" />
</div>
<div className="h-3 bg-slate-100 rounded w-24 hidden md:block" />
<div className="h-3 bg-slate-100 rounded w-20 hidden lg:block" />
<div className="h-6 bg-slate-100 rounded w-16 hidden lg:block" />
<div className="w-8 h-8 bg-slate-100 rounded" />
</div>
))}
</div>
);
// =============================================================================
// Empty State Component
// =============================================================================
interface EmptyStateProps {
onAddContact: () => void;
hasSearchQuery?: boolean;
searchQuery?: string;
onClearSearch?: () => void;
}
const EmptyState: React.FC<EmptyStateProps> = ({
onAddContact,
hasSearchQuery = false,
searchQuery = '',
onClearSearch
}) => (
<div className="text-center py-16 px-6">
<div className="mx-auto w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-6">
{hasSearchQuery ? (
<Search className="w-10 h-10 text-slate-400" />
) : (
<Users className="w-10 h-10 text-slate-400" />
)}
</div>
{hasSearchQuery ? (
<>
<h3 className="text-lg font-semibold text-slate-900 mb-2">No contacts found</h3>
<p className="text-slate-500 mb-6 max-w-sm mx-auto">
No contacts match your search for &quot;{searchQuery}&quot;. Try adjusting your search terms or filters.
</p>
<button
onClick={onClearSearch}
className="clay-btn px-5 py-3 font-semibold inline-flex items-center gap-2"
>
<X size={18} />
Clear Search
</button>
</>
) : (
<>
<h3 className="text-lg font-semibold text-slate-900 mb-2">No contacts yet</h3>
<p className="text-slate-500 mb-6 max-w-sm mx-auto">
Get started by adding your first contact or import contacts from a CSV file.
</p>
<button
onClick={onAddContact}
className="clay-btn-primary px-5 py-3 font-semibold inline-flex items-center gap-2"
>
<Plus size={18} />
Add Your First Contact
</button>
</>
)}
</div>
);
// =============================================================================
// Error State Component
// =============================================================================
const ErrorState: React.FC<{ error: string; onRetry: () => void }> = ({ error, onRetry }) => (
<div className="text-center py-16 px-6">
<div className="mx-auto w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Failed to load contacts</h3>
<p className="text-slate-500 mb-6">{error}</p>
<button onClick={onRetry} className="clay-btn px-5 py-3 font-semibold">
Try Again
</button>
</div>
);
// =============================================================================
// Tag Input Component
// =============================================================================
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}
const TagInput: React.FC<TagInputProps> = ({ tags, onChange, placeholder = 'Add tags...' }) => {
const [inputValue, setInputValue] = useState('');
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if ((e.key === 'Enter' || e.key === ',') && inputValue.trim()) {
e.preventDefault();
const newTag = inputValue.trim().replace(/,/g, '');
if (newTag && !tags.includes(newTag)) {
onChange([...tags, newTag]);
}
setInputValue('');
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
onChange(tags.slice(0, -1));
}
};
const removeTag = (tagToRemove: string) => {
onChange(tags.filter(tag => tag !== tagToRemove));
};
return (
<div className="clay-input w-full py-2 px-3 min-h-[48px] flex flex-wrap gap-2 items-center cursor-text focus-within:ring-2 focus-within:ring-indigo-500/20">
{tags.map((tag, index) => (
<TagChip key={index} tag={tag} onRemove={() => removeTag(tag)} size="md" />
))}
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={tags.length === 0 ? placeholder : ''}
className="flex-1 min-w-[120px] bg-transparent outline-none text-slate-900 placeholder:text-slate-400"
/>
</div>
);
};
// =============================================================================
// Contact Modal Component (Add/Edit)
// =============================================================================
interface ContactModalProps {
isOpen: boolean;
onClose: () => void;
contact?: Contact | null;
onSave: (data: ContactFormData) => void;
isLoading?: boolean;
}
const ContactModal: React.FC<ContactModalProps> = ({
isOpen,
onClose,
contact,
onSave,
isLoading = false,
}) => {
const [formData, setFormData] = useState<ContactFormData>({
firstName: '',
lastName: '',
email: '',
phone: '',
tags: [],
companyName: '',
});
useEffect(() => {
if (contact) {
setFormData({
firstName: contact.firstName || '',
lastName: contact.lastName || '',
email: contact.email || '',
phone: contact.phone || '',
tags: contact.tags || [],
companyName: contact.companyName || '',
});
} else {
setFormData({
firstName: '',
lastName: '',
email: '',
phone: '',
tags: [],
companyName: '',
});
}
}, [contact, isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave(formData);
};
const handleChange = (field: keyof Omit<ContactFormData, 'tags'>) => (
e: React.ChangeEvent<HTMLInputElement>
) => {
const limit = FIELD_LIMITS[field as keyof typeof FIELD_LIMITS];
const value = limit ? e.target.value.slice(0, limit) : e.target.value;
setFormData((prev) => ({ ...prev, [field]: value }));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
className="relative bg-[#F0F4F8] rounded-3xl shadow-[8px_8px_16px_#D1D9E6,-8px_-8px_16px_#FFFFFF] w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="contact-modal-title"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<h2 id="contact-modal-title" className="text-xl font-bold text-slate-900">
{contact ? 'Edit Contact' : 'Add Contact'}
</h2>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-8 space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.firstName}
onChange={handleChange('firstName')}
className="clay-input w-full py-3"
placeholder="John"
required
maxLength={FIELD_LIMITS.firstName}
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-slate-400">
{formData.firstName.length}/{FIELD_LIMITS.firstName}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.lastName}
onChange={handleChange('lastName')}
className="clay-input w-full py-3"
placeholder="Doe"
required
maxLength={FIELD_LIMITS.lastName}
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-slate-400">
{formData.lastName.length}/{FIELD_LIMITS.lastName}
</span>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Email
</label>
<input
type="email"
value={formData.email}
onChange={handleChange('email')}
className="clay-input w-full py-3"
placeholder="john@example.com"
maxLength={FIELD_LIMITS.email}
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-slate-400">
{formData.email.length}/{FIELD_LIMITS.email}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Phone
</label>
<input
type="tel"
value={formData.phone}
onChange={handleChange('phone')}
className="clay-input w-full py-3"
placeholder="(555) 123-4567"
maxLength={FIELD_LIMITS.phone}
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-slate-400">
{formData.phone.length}/{FIELD_LIMITS.phone}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Company
</label>
<input
type="text"
value={formData.companyName}
onChange={handleChange('companyName')}
className="clay-input w-full py-3"
placeholder="Acme Corp"
maxLength={FIELD_LIMITS.companyName}
/>
<div className="flex justify-end mt-1">
<span className="text-xs text-slate-400">
{formData.companyName.length}/{FIELD_LIMITS.companyName}
</span>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Tags
</label>
<TagInput
tags={formData.tags}
onChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
placeholder="Type and press Enter to add tags"
/>
<p className="text-xs text-slate-500 mt-1">Press Enter or comma to add a tag</p>
</div>
{/* Actions */}
<div className="flex gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="clay-btn flex-1 px-5 py-3 font-semibold"
>
Cancel
</button>
<button type="submit" className="clay-btn-primary flex-1 px-5 py-3 font-semibold flex items-center justify-center gap-2" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Saving...
</>
) : contact ? (
'Save Changes'
) : (
'Add Contact'
)}
</button>
</div>
</form>
</div>
</div>
);
};
// =============================================================================
// View Contact Modal Component
// =============================================================================
interface ViewContactModalProps {
isOpen: boolean;
onClose: () => void;
contact: Contact | null;
onEdit: () => void;
}
const ViewContactModal: React.FC<ViewContactModalProps> = ({
isOpen,
onClose,
contact,
onEdit,
}) => {
if (!isOpen || !contact) return null;
const handleCall = () => {
if (contact.phone) {
window.location.href = `tel:${contact.phone}`;
}
};
const handleEmail = () => {
if (contact.email) {
window.location.href = `mailto:${contact.email}`;
}
};
const handleMessage = () => {
if (contact.phone) {
window.location.href = `sms:${contact.phone}`;
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
className="relative bg-[#F0F4F8] rounded-3xl shadow-[8px_8px_16px_#D1D9E6,-8px_-8px_16px_#FFFFFF] w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
aria-labelledby="view-contact-modal-title"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<h2 id="view-contact-modal-title" className="text-xl font-bold text-slate-900">Contact Details</h2>
<button
onClick={onClose}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-xl transition-colors"
aria-label="Close modal"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-8">
{/* Avatar and Name */}
<div className="flex items-center gap-4 mb-4">
<div className="w-16 h-16 bg-indigo-100 rounded-full flex items-center justify-center">
<User size={28} className="text-indigo-600" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-slate-900">
{getContactDisplayName(contact)}
</h3>
{contact.companyName && (
<p className="text-slate-500">{contact.companyName}</p>
)}
</div>
</div>
{/* Quick Action Buttons */}
<div className="flex gap-2 mb-6">
<button
onClick={handleCall}
disabled={!contact.phone}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-emerald-50 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-emerald-200"
aria-label={contact.phone ? `Call ${getContactDisplayName(contact)}` : 'No phone number available'}
>
<Phone size={18} />
<span className="font-medium">Call</span>
</button>
<button
onClick={handleEmail}
disabled={!contact.email}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-blue-50 text-blue-700 hover:bg-blue-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-blue-200"
aria-label={contact.email ? `Email ${getContactDisplayName(contact)}` : 'No email available'}
>
<Mail size={18} />
<span className="font-medium">Email</span>
</button>
<button
onClick={handleMessage}
disabled={!contact.phone}
className="flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-purple-50 text-purple-700 hover:bg-purple-100 disabled:opacity-50 disabled:cursor-not-allowed transition-colors border border-purple-200"
aria-label={contact.phone ? `Message ${getContactDisplayName(contact)}` : 'No phone number available'}
>
<MessageSquare size={18} />
<span className="font-medium">Message</span>
</button>
</div>
{/* Contact Info */}
<div className="space-y-6">
{contact.email && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Mail size={18} className="text-slate-500" />
</div>
<div>
<p className="text-xs text-slate-500">Email</p>
<a href={`mailto:${contact.email}`} className="text-slate-900 hover:text-indigo-600 transition-colors">
{contact.email}
</a>
</div>
</div>
)}
{contact.phone && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Phone size={18} className="text-slate-500" />
</div>
<div>
<p className="text-xs text-slate-500">Phone</p>
<a href={`tel:${contact.phone}`} className="text-slate-900 hover:text-indigo-600 transition-colors">
{contact.phone}
</a>
</div>
</div>
)}
{contact.companyName && (
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Building size={18} className="text-slate-500" />
</div>
<div>
<p className="text-xs text-slate-500">Company</p>
<p className="text-slate-900">{contact.companyName}</p>
</div>
</div>
)}
{contact.tags && contact.tags.length > 0 && (
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Tag size={18} className="text-slate-500" />
</div>
<div>
<p className="text-xs text-slate-500 mb-2">Tags</p>
<div className="flex flex-wrap gap-2">
{contact.tags.map((tag, index) => (
<TagChip key={index} tag={tag} size="md" />
))}
</div>
</div>
</div>
)}
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Calendar size={18} className="text-slate-500" />
</div>
<div>
<p className="text-xs text-slate-500">Added</p>
<p className="text-slate-900">{formatDate(contact.createdAt)}</p>
</div>
</div>
</div>
{/* Activity Section */}
<div className="mt-6 pt-6 border-t border-slate-200">
<div className="flex items-center gap-2 mb-4">
<Activity size={18} className="text-slate-500" />
<h4 className="font-semibold text-slate-900">Recent Activity</h4>
</div>
<div className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-slate-50 rounded-xl">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center flex-shrink-0">
<Clock size={14} className="text-slate-400" />
</div>
<div>
<p className="text-sm text-slate-600">No recent activity</p>
<p className="text-xs text-slate-400 mt-0.5">Activity will appear here once you start interacting with this contact</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-3 mt-8">
<button onClick={onClose} className="clay-btn flex-1 px-5 py-3 font-semibold">
Close
</button>
<button onClick={onEdit} className="clay-btn-primary flex-1 px-5 py-3 font-semibold flex items-center justify-center gap-2">
<Edit size={18} />
Edit Contact
</button>
</div>
</div>
</div>
</div>
);
};
// =============================================================================
// Delete Confirmation Modal
// =============================================================================
interface DeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
contactName: string;
isLoading?: boolean;
}
const DeleteModal: React.FC<DeleteModalProps> = ({
isOpen,
onClose,
onConfirm,
contactName,
isLoading = false,
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal */}
<div
className="relative bg-[#F0F4F8] rounded-3xl shadow-[8px_8px_16px_#D1D9E6,-8px_-8px_16px_#FFFFFF] w-full max-w-md mx-4 p-6"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
>
<div className="text-center">
<div className="mx-auto w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
<Trash2 className="w-8 h-8 text-red-600" />
</div>
<h3 id="delete-modal-title" className="text-xl font-bold text-slate-900 mb-2">Delete Contact</h3>
<p id="delete-modal-description" className="text-slate-500 mb-6">
Are you sure you want to delete <span className="font-semibold">{contactName}</span>?
This action cannot be undone.
</p>
<div className="flex gap-3">
<button onClick={onClose} className="clay-btn flex-1 px-5 py-3 font-semibold">
Cancel
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className="flex-1 px-5 py-3 rounded-2xl font-semibold transition-all duration-200
bg-red-600 text-white hover:bg-red-700
shadow-[6px_6px_12px_rgba(220,38,38,0.3),-4px_-4px_8px_rgba(255,255,255,0.2)]
hover:shadow-[4px_4px_8px_rgba(220,38,38,0.3),-2px_-2px_4px_rgba(255,255,255,0.2)]
active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed
flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<Loader2 size={18} className="animate-spin" />
Deleting...
</>
) : (
<>
<Trash2 size={18} />
Delete
</>
)}
</button>
</div>
</div>
</div>
</div>
);
};
// =============================================================================
// Action Menu Component
// =============================================================================
interface ActionMenuProps {
onView: () => void;
onEdit: () => void;
onDelete: () => void;
contactName: string;
}
const ActionMenu: React.FC<ActionMenuProps> = ({ onView, onEdit, onDelete, contactName }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
setIsOpen(!isOpen);
}}
className="clay-icon-btn p-2"
aria-label={`Actions for ${contactName}`}
aria-haspopup="menu"
aria-expanded={isOpen}
>
<MoreVertical size={18} />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div
className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl shadow-lg border border-slate-200 py-1 min-w-[140px]"
role="menu"
aria-label={`Actions menu for ${contactName}`}
>
<button
onClick={(e) => {
e.stopPropagation();
onView();
setIsOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
role="menuitem"
>
<Eye size={16} />
View
</button>
<button
onClick={(e) => {
e.stopPropagation();
onEdit();
setIsOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-slate-700 hover:bg-slate-50 flex items-center gap-2"
role="menuitem"
>
<Edit size={16} />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
setIsOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
role="menuitem"
>
<Trash2 size={16} />
Delete
</button>
</div>
</>
)}
</div>
);
};
// =============================================================================
// Sortable Column Header Component
// =============================================================================
interface SortableHeaderProps {
field: SortField;
label: string;
currentSort: SortConfig;
onSort: (field: SortField) => void;
className?: string;
}
const SortableHeader: React.FC<SortableHeaderProps> = ({
field,
label,
currentSort,
onSort,
className = '',
}) => {
const isActive = currentSort.field === field;
const isAsc = currentSort.direction === 'asc';
return (
<th className={`text-left py-5 px-8 ${className}`}>
<button
onClick={() => onSort(field)}
className="flex items-center gap-1 text-sm font-semibold text-slate-600 hover:text-slate-900 transition-colors group"
aria-label={`Sort by ${label} ${isActive && isAsc ? 'descending' : 'ascending'}`}
>
{label}
<span className={`flex flex-col ${isActive ? 'text-indigo-600' : 'text-slate-300 group-hover:text-slate-400'}`}>
<ChevronUp
size={12}
className={`-mb-1 ${isActive && isAsc ? 'text-indigo-600' : ''}`}
/>
<ChevronDown
size={12}
className={`-mt-1 ${isActive && !isAsc ? 'text-indigo-600' : ''}`}
/>
</span>
</button>
</th>
);
};
// =============================================================================
// Contact Card Component (Mobile)
// =============================================================================
interface ContactCardProps {
contact: Contact;
onView: () => void;
onEdit: () => void;
onDelete: () => void;
}
const ContactCard: React.FC<ContactCardProps> = ({ contact, onView, onEdit, onDelete }) => (
<ClayCard className="mb-4 cursor-pointer hover:shadow-lg transition-shadow" onClick={onView}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0">
<User size={20} className="text-indigo-600" />
</div>
<div>
<h3 className="font-semibold text-slate-900">{getContactDisplayName(contact)}</h3>
{contact.companyName && (
<p className="text-sm text-slate-500">{contact.companyName}</p>
)}
</div>
</div>
<ActionMenu
onView={onView}
onEdit={onEdit}
onDelete={onDelete}
contactName={getContactDisplayName(contact)}
/>
</div>
<div className="mt-4 space-y-2">
{contact.email && (
<div className="flex items-center gap-2 text-sm text-slate-600">
<Mail size={14} className="text-slate-400" />
{contact.email}
</div>
)}
{contact.phone && (
<div className="flex items-center gap-2 text-sm text-slate-600">
<Phone size={14} className="text-slate-400" />
{contact.phone}
</div>
)}
</div>
{contact.tags && contact.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{contact.tags.map((tag, index) => (
<TagChip key={index} tag={tag} size="sm" />
))}
</div>
)}
<div className="mt-3 pt-3 border-t border-slate-200">
<p className="text-xs text-slate-400">Added {formatDate(contact.createdAt)}</p>
</div>
</ClayCard>
);
// =============================================================================
// Contacts Table Component (Desktop)
// =============================================================================
interface ContactsTableProps {
contacts: Contact[];
onView: (contact: Contact) => void;
onEdit: (contact: Contact) => void;
onDelete: (contact: Contact) => void;
sortConfig: SortConfig;
onSort: (field: SortField) => void;
}
const ContactsTable: React.FC<ContactsTableProps> = ({
contacts,
onView,
onEdit,
onDelete,
sortConfig,
onSort,
}) => (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-slate-200 bg-slate-50/70">
<SortableHeader
field="name"
label="Name"
currentSort={sortConfig}
onSort={onSort}
/>
<SortableHeader
field="email"
label="Email"
currentSort={sortConfig}
onSort={onSort}
className="hidden md:table-cell"
/>
<SortableHeader
field="phone"
label="Phone"
currentSort={sortConfig}
onSort={onSort}
className="hidden lg:table-cell"
/>
<th className="text-left py-5 px-8 text-sm font-semibold text-slate-600 hidden lg:table-cell">
Tags
</th>
<SortableHeader
field="createdAt"
label="Created"
currentSort={sortConfig}
onSort={onSort}
className="hidden xl:table-cell"
/>
<th className="text-right py-5 px-8 text-sm font-semibold text-slate-600">Actions</th>
</tr>
</thead>
<tbody>
{contacts.map((contact) => (
<tr
key={contact.id}
onClick={() => onView(contact)}
className="border-b border-slate-100 hover:bg-indigo-50/50 transition-colors cursor-pointer group"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onView(contact);
}
}}
role="button"
aria-label={`View details for ${getContactDisplayName(contact)}`}
>
<td className="py-5 px-8">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-indigo-100 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-indigo-200 transition-colors">
<User size={18} className="text-indigo-600" />
</div>
<div className="min-w-0">
<p className="font-semibold text-slate-900 group-hover:text-indigo-700 transition-colors truncate mb-1">
{getContactDisplayName(contact)}
</p>
{contact.companyName && (
<p className="text-sm text-slate-500 truncate">{contact.companyName}</p>
)}
</div>
</div>
</td>
<td className="py-5 px-8 text-slate-600 hidden md:table-cell">
{contact.email || <span className="text-slate-400">-</span>}
</td>
<td className="py-5 px-8 text-slate-600 hidden lg:table-cell">
{contact.phone || <span className="text-slate-400">-</span>}
</td>
<td className="py-5 px-8 hidden lg:table-cell">
<div className="flex flex-wrap gap-2">
{contact.tags?.slice(0, 2).map((tag, index) => (
<TagChip key={index} tag={tag} size="sm" />
))}
{contact.tags && contact.tags.length > 2 && (
<span className="text-xs px-2 py-0.5 rounded-full bg-slate-100 text-slate-600 border border-slate-200">
+{contact.tags.length - 2}
</span>
)}
{(!contact.tags || contact.tags.length === 0) && (
<span className="text-slate-400">-</span>
)}
</div>
</td>
<td className="py-5 px-8 text-slate-500 text-sm hidden xl:table-cell">
{formatDate(contact.createdAt)}
</td>
<td className="py-5 px-8 text-right">
<ActionMenu
onView={() => onView(contact)}
onEdit={() => onEdit(contact)}
onDelete={() => onDelete(contact)}
contactName={getContactDisplayName(contact)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
// =============================================================================
// Filter Dropdown Component
// =============================================================================
interface FilterDropdownProps {
value: string;
onChange: (value: string) => void;
}
const FilterDropdown: React.FC<FilterDropdownProps> = ({ value, onChange }) => {
const [isOpen, setIsOpen] = useState(false);
const selectedOption = FILTER_OPTIONS.find(opt => opt.value === value) || FILTER_OPTIONS[0];
return (
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="clay-btn px-4 py-3 font-medium flex items-center gap-2 min-w-[160px] justify-between"
aria-label="Filter contacts"
aria-haspopup="listbox"
aria-expanded={isOpen}
>
<div className="flex items-center gap-2">
<Filter size={18} className="text-slate-500" />
<span>{selectedOption.label}</span>
</div>
<ChevronDown size={16} className={`text-slate-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} aria-hidden="true" />
<div
className="absolute left-0 top-full mt-1 z-20 bg-white rounded-xl shadow-lg border border-slate-200 py-1 min-w-[200px]"
role="listbox"
aria-label="Filter options"
>
{FILTER_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => {
onChange(option.value);
setIsOpen(false);
}}
className={`w-full px-4 py-2 text-left text-sm hover:bg-slate-50 flex items-center gap-2 ${
value === option.value ? 'text-indigo-600 bg-indigo-50 font-medium' : 'text-slate-700'
}`}
role="option"
aria-selected={value === option.value}
>
{option.label}
</button>
))}
</div>
</>
)}
</div>
);
};
// =============================================================================
// Main Page Component
// =============================================================================
export default function ContactsPage() {
// State
const [contacts, setContacts] = useState<Contact[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [filterValue, setFilterValue] = useState('all');
const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'name', direction: 'asc' });
// Modal states
const [isAddEditModalOpen, setIsAddEditModalOpen] = useState(false);
const [isViewModalOpen, setIsViewModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Fetch contacts from GHL
const fetchContacts = async () => {
setIsLoading(true);
setError(null);
try {
const response = await api.contacts.getAll({ limit: 100 });
// Map GHL response to our Contact interface
const ghlContacts: Contact[] = (response.data || []).map((c: any) => ({
id: c.id,
firstName: c.firstName,
lastName: c.lastName,
name: c.name,
email: c.email,
phone: c.phone,
tags: c.tags || [],
createdAt: c.dateAdded || c.createdAt || new Date().toISOString(),
companyName: c.companyName,
}));
setContacts(ghlContacts);
} catch (err: any) {
console.error('Failed to fetch contacts:', err);
if (err.message?.includes('GHL not configured')) {
setError('GHL is not configured. Please set up your GHL credentials in Admin → Settings.');
} else {
setError('Unable to load contacts. Please try again.');
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchContacts();
}, []);
// Sort handler
const handleSort = useCallback((field: SortField) => {
setSortConfig((prev) => ({
field,
direction: prev.field === field && prev.direction === 'asc' ? 'desc' : 'asc',
}));
}, []);
// Filtered and sorted contacts
const filteredAndSortedContacts = useMemo(() => {
let result = [...contacts];
// Apply filter
if (filterValue !== 'all') {
result = result.filter((contact) => {
switch (filterValue) {
case 'recent':
return isWithinLastWeek(contact.createdAt);
case 'investor':
return contact.tags?.some(tag => tag.toLowerCase() === 'investor');
case 'buyer':
return contact.tags?.some(tag => tag.toLowerCase() === 'buyer');
case 'hot-lead':
return contact.tags?.some(tag => tag.toLowerCase() === 'hot lead');
case 'vip':
return contact.tags?.some(tag => tag.toLowerCase() === 'vip');
default:
return true;
}
});
}
// Apply search
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter((contact) => {
const name = getContactDisplayName(contact).toLowerCase();
const email = contact.email?.toLowerCase() || '';
const phone = contact.phone?.toLowerCase() || '';
const company = contact.companyName?.toLowerCase() || '';
const tags = contact.tags?.join(' ').toLowerCase() || '';
return (
name.includes(query) ||
email.includes(query) ||
phone.includes(query) ||
company.includes(query) ||
tags.includes(query)
);
});
}
// Apply sort
result.sort((a, b) => {
let aValue: string;
let bValue: string;
switch (sortConfig.field) {
case 'name':
aValue = getContactDisplayName(a).toLowerCase();
bValue = getContactDisplayName(b).toLowerCase();
break;
case 'email':
aValue = a.email?.toLowerCase() || '';
bValue = b.email?.toLowerCase() || '';
break;
case 'phone':
aValue = a.phone || '';
bValue = b.phone || '';
break;
case 'createdAt':
aValue = a.createdAt;
bValue = b.createdAt;
break;
default:
return 0;
}
if (aValue < bValue) return sortConfig.direction === 'asc' ? -1 : 1;
if (aValue > bValue) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
return result;
}, [contacts, searchQuery, filterValue, sortConfig]);
// Handlers
const handleAddContact = () => {
setSelectedContact(null);
setIsAddEditModalOpen(true);
};
const handleViewContact = (contact: Contact) => {
setSelectedContact(contact);
setIsViewModalOpen(true);
};
const handleEditContact = (contact: Contact) => {
setSelectedContact(contact);
setIsAddEditModalOpen(true);
setIsViewModalOpen(false);
};
const handleDeleteContact = (contact: Contact) => {
setSelectedContact(contact);
setIsDeleteModalOpen(true);
};
const handleClearSearch = () => {
setSearchQuery('');
setFilterValue('all');
};
const handleSaveContact = async (formData: ContactFormData) => {
setIsSaving(true);
try {
if (selectedContact) {
// Update existing contact in GHL
await api.contacts.update(selectedContact.id, {
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email || undefined,
phone: formData.phone || undefined,
tags: formData.tags,
});
// Refresh contacts list
await fetchContacts();
} else {
// Create new contact in GHL
await api.contacts.create({
firstName: formData.firstName,
lastName: formData.lastName,
email: formData.email || undefined,
phone: formData.phone || undefined,
tags: formData.tags,
});
// Refresh contacts list
await fetchContacts();
}
setIsAddEditModalOpen(false);
setSelectedContact(null);
} catch (err) {
console.error('Failed to save contact:', err);
setError('Failed to save contact. Please try again.');
} finally {
setIsSaving(false);
}
};
const handleConfirmDelete = async () => {
if (!selectedContact) return;
setIsDeleting(true);
try {
// Delete contact in GHL
await api.contacts.delete(selectedContact.id);
// Refresh contacts list
await fetchContacts();
setIsDeleteModalOpen(false);
setSelectedContact(null);
} catch (err) {
console.error('Failed to delete contact:', err);
setError('Failed to delete contact. Please try again.');
} finally {
setIsDeleting(false);
}
};
return (
<div className="space-y-6">
{/* Header - Level 1 (subtle) since it's a page header */}
<div className="clay-card-subtle p-6 border border-border/50">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl lg:text-4xl font-bold text-foreground mb-2">Contacts</h1>
<p className="text-slate-500 text-lg">Manage your leads and clients</p>
</div>
<button onClick={handleAddContact} className="clay-btn-primary px-6 py-3 font-semibold flex items-center gap-2 rounded-xl">
<Plus size={20} />
Add Contact
</button>
</div>
</div>
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search
className="absolute left-4 top-1/2 transform -translate-y-1/2 text-slate-500 z-10 pointer-events-none"
size={20}
/>
<input
type="text"
placeholder="Search contacts by name, email, phone, or tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={`clay-input clay-input-icon w-full h-14 ${searchQuery ? 'pr-12' : 'pr-4'} text-base border-2 border-slate-200 focus:border-indigo-400 transition-colors`}
aria-label="Search contacts"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
aria-label="Clear search"
>
<X size={16} />
</button>
)}
</div>
<FilterDropdown value={filterValue} onChange={setFilterValue} />
</div>
{/* Active filter indicator */}
{(filterValue !== 'all' || searchQuery) && (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-slate-500">Active filters:</span>
{filterValue !== 'all' && (
<span className="inline-flex items-center gap-1 text-sm px-3 py-1 rounded-full bg-indigo-50 text-indigo-700 border border-indigo-200">
{FILTER_OPTIONS.find(opt => opt.value === filterValue)?.label}
<button
onClick={() => setFilterValue('all')}
className="ml-1 hover:text-indigo-900"
aria-label="Remove filter"
>
<X size={14} />
</button>
</span>
)}
{searchQuery && (
<span className="inline-flex items-center gap-1 text-sm px-3 py-1 rounded-full bg-slate-100 text-slate-700 border border-slate-200">
Search: &quot;{searchQuery}&quot;
<button
onClick={() => setSearchQuery('')}
className="ml-1 hover:text-slate-900"
aria-label="Clear search"
>
<X size={14} />
</button>
</span>
)}
<button
onClick={handleClearSearch}
className="text-sm text-indigo-600 hover:text-indigo-700 font-medium ml-2"
>
Clear all
</button>
</div>
)}
{/* Contacts List */}
<div className="clay-card border border-border/50 overflow-hidden">
{isLoading ? (
<div className="p-6">
<TableSkeleton />
</div>
) : error ? (
<ErrorState error={error} onRetry={fetchContacts} />
) : filteredAndSortedContacts.length === 0 ? (
<EmptyState
onAddContact={handleAddContact}
hasSearchQuery={!!searchQuery || filterValue !== 'all'}
searchQuery={searchQuery || FILTER_OPTIONS.find(opt => opt.value === filterValue)?.label || ''}
onClearSearch={handleClearSearch}
/>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block">
<ContactsTable
contacts={filteredAndSortedContacts}
onView={handleViewContact}
onEdit={handleEditContact}
onDelete={handleDeleteContact}
sortConfig={sortConfig}
onSort={handleSort}
/>
</div>
{/* Mobile Cards */}
<div className="md:hidden p-6">
{filteredAndSortedContacts.map((contact) => (
<ContactCard
key={contact.id}
contact={contact}
onView={() => handleViewContact(contact)}
onEdit={() => handleEditContact(contact)}
onDelete={() => handleDeleteContact(contact)}
/>
))}
</div>
</>
)}
</div>
{/* Results count */}
{!isLoading && !error && filteredAndSortedContacts.length > 0 && (
<p className="text-sm text-slate-500 text-center mt-4">
Showing {filteredAndSortedContacts.length} of {contacts.length} contacts
</p>
)}
{/* Modals */}
<ContactModal
isOpen={isAddEditModalOpen}
onClose={() => {
setIsAddEditModalOpen(false);
setSelectedContact(null);
}}
contact={selectedContact}
onSave={handleSaveContact}
isLoading={isSaving}
/>
<ViewContactModal
isOpen={isViewModalOpen}
onClose={() => {
setIsViewModalOpen(false);
setSelectedContact(null);
}}
contact={selectedContact}
onEdit={() => handleEditContact(selectedContact!)}
/>
<DeleteModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setSelectedContact(null);
}}
onConfirm={handleConfirmDelete}
contactName={selectedContact ? getContactDisplayName(selectedContact) : ''}
isLoading={isDeleting}
/>
</div>
);
}