feat: initial SPA frontend for network app
This commit is contained in:
282
src/pages/ClientDetailPage.tsx
Normal file
282
src/pages/ClientDetailPage.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Event, Email } from '@/types';
|
||||
import {
|
||||
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
||||
CheckCircle2, Sparkles, Clock,
|
||||
} from 'lucide-react';
|
||||
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
||||
import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import ClientForm from '@/components/ClientForm';
|
||||
import EmailComposeModal from '@/components/EmailComposeModal';
|
||||
|
||||
export default function ClientDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { selectedClient, isLoading, fetchClient, updateClient, deleteClient, markContacted } = useClientsStore();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'events' | 'emails'>('info');
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
fetchClient(id);
|
||||
api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
|
||||
api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
|
||||
}
|
||||
}, [id, fetchClient]);
|
||||
|
||||
if (isLoading || !selectedClient) return <PageLoader />;
|
||||
|
||||
const client = selectedClient;
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Delete this client? This cannot be undone.')) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deleteClient(client.id);
|
||||
navigate('/clients');
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkContacted = async () => {
|
||||
await markContacted(client.id);
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: any) => {
|
||||
await updateClient(client.id, data);
|
||||
setShowEdit(false);
|
||||
};
|
||||
|
||||
const tabs: { key: 'info' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [
|
||||
{ key: 'info', label: 'Info', icon: Users },
|
||||
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-4">
|
||||
<button onClick={() => navigate('/clients')} className="mt-1 p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-blue-100 text-blue-700 rounded-2xl flex items-center justify-center text-xl font-bold">
|
||||
{getInitials(client.firstName, client.lastName)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900">
|
||||
{client.firstName} {client.lastName}
|
||||
</h1>
|
||||
{client.company && (
|
||||
<p className="text-slate-500">
|
||||
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||
</p>
|
||||
)}
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleMarkContacted} className="flex items-center gap-2 px-3 py-2 bg-emerald-50 text-emerald-700 rounded-lg text-sm font-medium hover:bg-emerald-100 transition-colors">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
Contacted
|
||||
</button>
|
||||
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Generate Email
|
||||
</button>
|
||||
<button onClick={() => setShowEdit(true)} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||
<Edit3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={deleting} className="p-2 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-slate-200">
|
||||
<div className="flex gap-6">
|
||||
{tabs.map(({ key, label, count, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 pb-3 border-b-2 text-sm font-medium transition-colors',
|
||||
activeTab === key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded-full text-xs">{count}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'info' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Contact Info */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5 space-y-4">
|
||||
<h3 className="font-semibold text-slate-900">Contact Information</h3>
|
||||
{client.email && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Mail className="w-4 h-4 text-slate-400" />
|
||||
<a href={`mailto:${client.email}`} className="text-blue-600 hover:underline">{client.email}</a>
|
||||
</div>
|
||||
)}
|
||||
{client.phone && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Phone className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-700">{client.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{(client.street || client.city) && (
|
||||
<div className="flex items-start gap-3 text-sm">
|
||||
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
|
||||
<span className="text-slate-700">
|
||||
{[client.street, client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{client.company && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Building2 className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-700">{client.company}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.industry && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Briefcase className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-700">{client.industry}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-500">Last contacted: {getRelativeTime(client.lastContacted)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personal */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5 space-y-4">
|
||||
<h3 className="font-semibold text-slate-900">Personal Details</h3>
|
||||
{client.birthday && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Gift className="w-4 h-4 text-pink-500" />
|
||||
<span className="text-slate-700">Birthday: {formatDate(client.birthday)}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.anniversary && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Heart className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-slate-700">Anniversary: {formatDate(client.anniversary)}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.interests && client.interests.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-500 mb-2">
|
||||
<Star className="w-4 h-4" />
|
||||
Interests
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{client.interests.map((i) => <Badge key={i} color="green">{i}</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{client.family?.spouse && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Users className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-slate-700">Spouse: {client.family.spouse}</span>
|
||||
</div>
|
||||
)}
|
||||
{client.family?.children && client.family.children.length > 0 && (
|
||||
<div className="text-sm text-slate-700 ml-7">
|
||||
Children: {client.family.children.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{client.notes && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5 md:col-span-2">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Notes</h3>
|
||||
<p className="text-sm text-slate-700 whitespace-pre-wrap">{client.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'events' && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||
{events.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">No events for this client</p>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<div key={event.id} className="flex items-center gap-3 px-5 py-4">
|
||||
<EventTypeBadge type={event.type} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{event.title}</p>
|
||||
<p className="text-xs text-slate-500">{formatDate(event.date)} {event.recurring && '· Recurring'}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'emails' && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||
{emails.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">No emails for this client</p>
|
||||
) : (
|
||||
emails.map((email) => (
|
||||
<Link key={email.id} to={`/emails?id=${email.id}`} className="flex items-center gap-3 px-5 py-4 hover:bg-slate-50 transition-colors">
|
||||
<EmailStatusBadge status={email.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">{email.subject}</p>
|
||||
<p className="text-xs text-slate-500">{formatDate(email.createdAt)}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Client" size="lg">
|
||||
<ClientForm initialData={client} onSubmit={handleUpdate} />
|
||||
</Modal>
|
||||
|
||||
{/* Email Compose Modal */}
|
||||
<EmailComposeModal
|
||||
isOpen={showCompose}
|
||||
onClose={() => setShowCompose(false)}
|
||||
clientId={client.id}
|
||||
clientName={`${client.firstName} ${client.lastName}`}
|
||||
onGenerated={(email) => setEmails((prev) => [email, ...prev])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/pages/ClientsPage.tsx
Normal file
180
src/pages/ClientsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Search, Plus, Users, X } from 'lucide-react';
|
||||
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||
import Badge from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import ClientForm from '@/components/ClientForm';
|
||||
|
||||
export default function ClientsPage() {
|
||||
const location = useLocation();
|
||||
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients();
|
||||
}, [fetchClients]);
|
||||
|
||||
useEffect(() => {
|
||||
if (location.state?.openCreate) {
|
||||
setShowCreate(true);
|
||||
window.history.replaceState({}, '');
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
// Client-side filtering for immediate feedback
|
||||
const filteredClients = useMemo(() => {
|
||||
let result = clients;
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(c) =>
|
||||
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
|
||||
c.email?.toLowerCase().includes(q) ||
|
||||
c.company?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (selectedTag) {
|
||||
result = result.filter((c) => c.tags?.includes(selectedTag));
|
||||
}
|
||||
return result;
|
||||
}, [clients, searchQuery, selectedTag]);
|
||||
|
||||
// All unique tags
|
||||
const allTags = useMemo(() => {
|
||||
const tags = new Set<string>();
|
||||
clients.forEach((c) => c.tags?.forEach((t) => tags.add(t)));
|
||||
return Array.from(tags).sort();
|
||||
}, [clients]);
|
||||
|
||||
const handleCreate = async (data: any) => {
|
||||
setCreating(true);
|
||||
try {
|
||||
await createClient(data);
|
||||
setShowCreate(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && clients.length === 0) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Clients</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{clients.length} contacts in your network</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search + Tags */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search clients..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
color="blue"
|
||||
active={selectedTag === tag}
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{selectedTag && (
|
||||
<button onClick={() => setSelectedTag(null)} className="text-xs text-slate-500 hover:text-slate-700 ml-1">
|
||||
Clear filter
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Client Grid */}
|
||||
{filteredClients.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title={searchQuery || selectedTag ? 'No matches found' : 'No clients yet'}
|
||||
description={searchQuery || selectedTag ? 'Try adjusting your search or filters' : 'Add your first client to get started'}
|
||||
action={!searchQuery && !selectedTag ? { label: 'Add Client', onClick: () => setShowCreate(true) } : undefined}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredClients.map((client) => (
|
||||
<Link
|
||||
key={client.id}
|
||||
to={`/clients/${client.id}`}
|
||||
className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md hover:border-slate-300 transition-all group"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-11 h-11 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 group-hover:bg-blue-200 transition-colors">
|
||||
{getInitials(client.firstName, client.lastName)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-slate-900 truncate">
|
||||
{client.firstName} {client.lastName}
|
||||
</h3>
|
||||
{client.company && (
|
||||
<p className="text-sm text-slate-500 truncate">{client.role ? `${client.role} at ` : ''}{client.company}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{client.tags && client.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{client.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full text-xs">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{client.tags.length > 3 && (
|
||||
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 text-xs text-slate-400">
|
||||
Last contacted: {getRelativeTime(client.lastContacted)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
|
||||
<ClientForm onSubmit={handleCreate} loading={creating} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/pages/DashboardPage.tsx
Normal file
151
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Client, Event, Email } from '@/types';
|
||||
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock } from 'lucide-react';
|
||||
import { formatDate, getDaysUntil } from '@/lib/utils';
|
||||
import { EventTypeBadge } from '@/components/Badge';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [emails, setEmails] = useState<Email[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
api.getClients().catch(() => []),
|
||||
api.getEvents({ upcoming: 7 }).catch(() => []),
|
||||
api.getEmails({ status: 'draft' }).catch(() => []),
|
||||
]).then(([c, e, em]) => {
|
||||
setClients(c);
|
||||
setEvents(e);
|
||||
setEmails(em);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
const stats = [
|
||||
{ label: 'Total Clients', value: clients.length, icon: Users, color: 'bg-blue-50 text-blue-600', link: '/clients' },
|
||||
{ label: 'Upcoming Events', value: events.length, icon: Calendar, color: 'bg-emerald-50 text-emerald-600', link: '/events' },
|
||||
{ label: 'Pending Drafts', value: emails.length, icon: Mail, color: 'bg-amber-50 text-amber-600', link: '/emails' },
|
||||
];
|
||||
|
||||
const eventIcon = (type: string) => {
|
||||
if (type === 'birthday') return <Gift className="w-4 h-4 text-pink-500" />;
|
||||
if (type === 'anniversary') return <Heart className="w-4 h-4 text-purple-500" />;
|
||||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Dashboard</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Welcome back. Here's your overview.</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/clients"
|
||||
state={{ openCreate: true }}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Client
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
{stats.map((stat) => (
|
||||
<Link
|
||||
key={stat.label}
|
||||
to={stat.link}
|
||||
className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${stat.color}`}>
|
||||
<stat.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
|
||||
<p className="text-sm text-slate-500">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upcoming Events */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">Upcoming Events</h2>
|
||||
<Link to="/events" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{events.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">No upcoming events</p>
|
||||
) : (
|
||||
events.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="flex items-center gap-3 px-5 py-3">
|
||||
{eventIcon(event.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">{event.title}</p>
|
||||
<p className="text-xs text-slate-500">{formatDate(event.date)}</p>
|
||||
</div>
|
||||
<EventTypeBadge type={event.type} />
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">
|
||||
{getDaysUntil(event.date)}d
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Clients */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-900">Recent Clients</h2>
|
||||
<Link to="/clients" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{clients.length === 0 ? (
|
||||
<p className="px-5 py-8 text-center text-sm text-slate-400">No clients yet</p>
|
||||
) : (
|
||||
[...clients]
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.slice(0, 5)
|
||||
.map((client) => (
|
||||
<Link
|
||||
key={client.id}
|
||||
to={`/clients/${client.id}`}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
<div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||
{client.firstName[0]}{client.lastName[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">
|
||||
{client.firstName} {client.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 truncate">
|
||||
{client.company || client.email || 'No details'}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
src/pages/EmailsPage.tsx
Normal file
236
src/pages/EmailsPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEmailsStore } from '@/stores/emails';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Mail, Send, Trash2, Edit3, Sparkles, Gift } from 'lucide-react';
|
||||
import { cn, formatDate } from '@/lib/utils';
|
||||
import { EmailStatusBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
|
||||
export default function EmailsPage() {
|
||||
const { emails, isLoading, isGenerating, statusFilter, setStatusFilter, fetchEmails, generateEmail, generateBirthdayEmail, updateEmail, sendEmail, deleteEmail } = useEmailsStore();
|
||||
const { clients, fetchClients } = useClientsStore();
|
||||
const [showCompose, setShowCompose] = useState(false);
|
||||
const [editingEmail, setEditingEmail] = useState<string | null>(null);
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editContent, setEditContent] = useState('');
|
||||
const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' });
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmails();
|
||||
fetchClients();
|
||||
}, [fetchEmails, fetchClients]);
|
||||
|
||||
const filtered = statusFilter ? emails.filter((e) => e.status === statusFilter) : emails;
|
||||
|
||||
const handleGenerate = async () => {
|
||||
try {
|
||||
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
||||
setShowCompose(false);
|
||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleGenerateBirthday = async () => {
|
||||
try {
|
||||
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
||||
setShowCompose(false);
|
||||
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const startEdit = (email: typeof emails[0]) => {
|
||||
setEditingEmail(email.id);
|
||||
setEditSubject(email.subject);
|
||||
setEditContent(email.content);
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingEmail) return;
|
||||
await updateEmail(editingEmail, { subject: editSubject, content: editContent });
|
||||
setEditingEmail(null);
|
||||
};
|
||||
|
||||
if (isLoading && emails.length === 0) return <PageLoader />;
|
||||
|
||||
const statusFilters = [
|
||||
{ key: null, label: 'All' },
|
||||
{ key: 'draft', label: 'Drafts' },
|
||||
{ key: 'sent', label: 'Sent' },
|
||||
{ key: 'failed', label: 'Failed' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Emails</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">AI-generated emails for your network</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCompose(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Compose
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2">
|
||||
{statusFilters.map(({ key, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
statusFilter === key
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Emails list */}
|
||||
{filtered.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title="No emails"
|
||||
description="Generate AI-powered emails for your clients"
|
||||
action={{ label: 'Compose Email', onClick: () => setShowCompose(true) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((email) => (
|
||||
<div key={email.id} className="bg-white border border-slate-200 rounded-xl p-5">
|
||||
{editingEmail === email.id ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setEditingEmail(null)} className="px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">Cancel</button>
|
||||
<button onClick={saveEdit} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<EmailStatusBadge status={email.status} />
|
||||
{email.client && (
|
||||
<span className="text-xs text-slate-500">
|
||||
To: {email.client.firstName} {email.client.lastName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{formatDate(email.createdAt)}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">{email.subject}</h3>
|
||||
<p className="text-sm text-slate-600 whitespace-pre-wrap line-clamp-4">{email.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-slate-100">
|
||||
{email.status === 'draft' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startEdit(email)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await sendEmail(email.id); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" /> Send
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={async () => { if (confirm('Delete this email?')) await deleteEmail(email.id); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose Modal */}
|
||||
<Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Client *</label>
|
||||
<select
|
||||
value={composeForm.clientId}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, clientId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Purpose</label>
|
||||
<textarea
|
||||
value={composeForm.purpose}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="What's this email about?"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Provider</label>
|
||||
<select
|
||||
value={composeForm.provider}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!composeForm.clientId || !composeForm.purpose || isGenerating}
|
||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isGenerating ? <LoadingSpinner size="sm" className="text-white" /> : <Sparkles className="w-4 h-4" />}
|
||||
Generate
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerateBirthday}
|
||||
disabled={!composeForm.clientId || isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-pink-50 text-pink-700 rounded-lg text-sm font-medium hover:bg-pink-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Gift className="w-4 h-4" />
|
||||
Birthday
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
src/pages/EventsPage.tsx
Normal file
266
src/pages/EventsPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEventsStore } from '@/stores/events';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star } from 'lucide-react';
|
||||
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
||||
import Badge, { EventTypeBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import type { EventCreate } from '@/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const eventTypes = [
|
||||
{ key: null, label: 'All', icon: Calendar },
|
||||
{ key: 'birthday', label: 'Birthdays', icon: Gift },
|
||||
{ key: 'anniversary', label: 'Anniversaries', icon: Heart },
|
||||
{ key: 'followup', label: 'Follow-ups', icon: Clock },
|
||||
{ key: 'custom', label: 'Custom', icon: Star },
|
||||
];
|
||||
|
||||
export default function EventsPage() {
|
||||
const { events, isLoading, typeFilter, setTypeFilter, fetchEvents, createEvent, deleteEvent, syncAll } = useEventsStore();
|
||||
const { clients, fetchClients } = useClientsStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
fetchClients();
|
||||
}, [fetchEvents, fetchClients]);
|
||||
|
||||
const filtered = typeFilter
|
||||
? events.filter((e) => e.type === typeFilter)
|
||||
: events;
|
||||
|
||||
const sorted = [...filtered].sort(
|
||||
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
);
|
||||
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await syncAll();
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && events.length === 0) return <PageLoader />;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Events</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{events.length} events tracked</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', syncing && 'animate-spin')} />
|
||||
Sync All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Event
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Type filters */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{eventTypes.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setTypeFilter(key)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
typeFilter === key
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No events"
|
||||
description={typeFilter ? 'No events of this type' : 'Create your first event or sync from clients'}
|
||||
action={{ label: 'Create Event', onClick: () => setShowCreate(true) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||
{sorted.map((event) => {
|
||||
const days = getDaysUntil(event.date);
|
||||
return (
|
||||
<div key={event.id} className="flex items-center gap-4 px-5 py-4">
|
||||
<div className={cn(
|
||||
'w-12 h-12 rounded-xl flex flex-col items-center justify-center text-xs font-semibold',
|
||||
days <= 1 ? 'bg-red-50 text-red-600' :
|
||||
days <= 7 ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-slate-50 text-slate-600'
|
||||
)}>
|
||||
<span className="text-lg leading-none">{new Date(event.date).getUTCDate()}</span>
|
||||
<span className="text-[10px] uppercase">{new Date(event.date).toLocaleString('en', { month: 'short' })}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{event.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<EventTypeBadge type={event.type} />
|
||||
{event.recurring && <span className="text-xs text-slate-400">Recurring</span>}
|
||||
{event.client && (
|
||||
<Link to={`/clients/${event.clientId}`} className="text-xs text-blue-600 hover:underline">
|
||||
{event.client.firstName} {event.client.lastName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
days <= 1 ? 'text-red-600' : days <= 7 ? 'text-amber-600' : 'text-slate-500'
|
||||
)}>
|
||||
{days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : `${days} days`}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{formatDate(event.date)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Delete this event?')) await deleteEvent(event.id);
|
||||
}}
|
||||
className="p-1.5 rounded text-slate-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<CreateEventModal
|
||||
isOpen={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
clients={clients}
|
||||
onCreate={async (data) => {
|
||||
await createEvent(data);
|
||||
setShowCreate(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
clients: { id: string; firstName: string; lastName: string }[];
|
||||
onCreate: (data: EventCreate) => Promise<void>;
|
||||
}) {
|
||||
const [form, setForm] = useState<EventCreate>({
|
||||
type: 'custom',
|
||||
title: '',
|
||||
date: '',
|
||||
clientId: '',
|
||||
recurring: false,
|
||||
reminderDays: 7,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = { ...form };
|
||||
if (!data.clientId) delete data.clientId;
|
||||
await onCreate(data);
|
||||
setForm({ type: 'custom', title: '', date: '', clientId: '', recurring: false, reminderDays: 7 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Create Event">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Title *</label>
|
||||
<input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Type</label>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as any })} className={inputClass}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="birthday">Birthday</option>
|
||||
<option value="anniversary">Anniversary</option>
|
||||
<option value="followup">Follow-up</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Date *</label>
|
||||
<input type="date" required value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Client</label>
|
||||
<select value={form.clientId} onChange={(e) => setForm({ ...form, clientId: e.target.value })} className={inputClass}>
|
||||
<option value="">None</option>
|
||||
{clients.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.recurring}
|
||||
onChange={(e) => setForm({ ...form, recurring: e.target.checked })}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
Recurring annually
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<label>Remind</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.reminderDays || ''}
|
||||
onChange={(e) => setForm({ ...form, reminderDays: Number(e.target.value) || undefined })}
|
||||
className="w-16 px-2 py-1 border border-slate-300 rounded text-sm"
|
||||
min={0}
|
||||
/>
|
||||
<span>days before</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{loading && <LoadingSpinner size="sm" className="text-white" />}
|
||||
Create Event
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
97
src/pages/LoginPage.tsx
Normal file
97
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Network, Eye, EyeOff } from 'lucide-react';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate('/');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">NetworkCRM</h1>
|
||||
<p className="text-slate-500 mt-1">Sign in to manage your network</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <LoadingSpinner size="sm" className="text-white" /> : null}
|
||||
{loading ? 'Signing in...' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
src/pages/SettingsPage.tsx
Normal file
148
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Profile } from '@/types';
|
||||
import { Save, User } from 'lucide-react';
|
||||
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getProfile().then((p) => {
|
||||
setProfile(p);
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!profile) return;
|
||||
setSaving(true);
|
||||
setSuccess(false);
|
||||
try {
|
||||
const updated = await api.updateProfile(profile);
|
||||
setProfile(updated);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 3000);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
const inputClass = 'w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
const labelClass = 'block text-sm font-medium text-slate-700 mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Manage your profile and preferences</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-6">
|
||||
{/* Profile section */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100">
|
||||
<div className="w-10 h-10 bg-blue-100 text-blue-700 rounded-lg flex items-center justify-center">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Profile Information</h2>
|
||||
<p className="text-xs text-slate-500">Your public-facing details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Name</label>
|
||||
<input
|
||||
value={profile?.name || ''}
|
||||
onChange={(e) => setProfile({ ...profile!, name: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Email</label>
|
||||
<input
|
||||
value={profile?.email || ''}
|
||||
disabled
|
||||
className={`${inputClass} bg-slate-50 text-slate-500`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className={labelClass}>Title</label>
|
||||
<input
|
||||
value={profile?.title || ''}
|
||||
onChange={(e) => setProfile({ ...profile!, title: e.target.value })}
|
||||
placeholder="e.g., Account Executive"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>Company</label>
|
||||
<input
|
||||
value={profile?.company || ''}
|
||||
onChange={(e) => setProfile({ ...profile!, company: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelClass}>Phone</label>
|
||||
<input
|
||||
value={profile?.phone || ''}
|
||||
onChange={(e) => setProfile({ ...profile!, phone: e.target.value })}
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
||||
<h2 className="font-semibold text-slate-900">Email Signature</h2>
|
||||
<textarea
|
||||
value={profile?.emailSignature || ''}
|
||||
onChange={(e) => setProfile({ ...profile!, emailSignature: e.target.value })}
|
||||
rows={6}
|
||||
placeholder="Your email signature (supports plain text)..."
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
{profile?.emailSignature && (
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-500 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-slate-50 rounded-lg text-sm whitespace-pre-wrap border border-slate-200">
|
||||
{profile.emailSignature}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? <LoadingSpinner size="sm" className="text-white" /> : <Save className="w-4 h-4" />}
|
||||
Save Changes
|
||||
</button>
|
||||
{success && (
|
||||
<span className="text-sm text-emerald-600 font-medium animate-fade-in">✓ Saved successfully</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user