- 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>
1666 lines
55 KiB
TypeScript
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 "{searchQuery}". 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: "{searchQuery}"
|
|
<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>
|
|
);
|
|
}
|