feat(leads): Phase 8 — Save action, lead list page, and nav update
Save action (CreateLeadPage): - Validate required fields: first/last name, 10-digit phone, address, city, lead source - On failure: Sonner error toast + auto-opens first incomplete section - On success: Sonner success toast with name + address, redirects to /emp/fa/leads - Desktop save bar with progress % indicator and validation error message - Back button navigates to /emp/fa/leads instead of browser history Leads List page (LeadsListPage.jsx): - Route: /emp/fa/leads (FIELD_AGENT, ADMIN, OWNER) - Lead cards: name, urgency + status badges, address, phone, lead source, time ago, creator - Search bar: filters by name, address, city, source, status - Empty state with CTA when no leads saved yet - Animated card entrance with staggered spring transitions mockStore: - Add leads[] state and addLead() action — prepends new lead with id, createdAt, status: New Navigation: - Sidebar "New Lead" replaced with "Leads" (ClipboardList icon) for all roles - New Lead button lives on the list page header Author: Satyam Rastogi
This commit is contained in:
@@ -30,6 +30,7 @@ import VendorOrders from './pages/vendor/VendorOrders';
|
|||||||
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
import ContractorDashboard from './pages/contractor/ContractorDashboard';
|
||||||
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
import SubContractorDashboard from './pages/subcontractor/SubContractorDashboard';
|
||||||
import CreateLeadPage from './pages/CreateLeadPage';
|
import CreateLeadPage from './pages/CreateLeadPage';
|
||||||
|
import LeadsListPage from './pages/LeadsListPage';
|
||||||
|
|
||||||
// ... (existing imports)
|
// ... (existing imports)
|
||||||
const ProtectedRoute = ({ children, allowedRoles }) => {
|
const ProtectedRoute = ({ children, allowedRoles }) => {
|
||||||
@@ -107,6 +108,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/emp/fa/leads"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['FIELD_AGENT', 'ADMIN', 'OWNER']}>
|
||||||
|
<LeadsListPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/emp/fa/leads/new"
|
path="/emp/fa/leads/new"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useTheme } from '../context/ThemeContext';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
LayoutDashboard, Map, Calendar, LogOut, User, Home, MessageSquare,
|
||||||
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
ChevronLeft, ChevronRight, Sun, Moon, Trophy, Users, Briefcase,
|
||||||
FileText, Menu, X, Calculator, PlusCircle
|
FileText, Menu, X, Calculator, PlusCircle, ClipboardList
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import PageTransition from './PageTransition';
|
import PageTransition from './PageTransition';
|
||||||
@@ -137,7 +137,7 @@ const Layout = () => {
|
|||||||
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Owners Box" },
|
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Owners Box" },
|
||||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
||||||
{ to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
{ to: "/owner/maps", icon: Map, label: "Territory Map" },
|
||||||
{
|
{
|
||||||
to: "/owner/pro-canvas",
|
to: "/owner/pro-canvas",
|
||||||
@@ -154,7 +154,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
{ to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||||
{
|
{
|
||||||
to: "/admin/pro-canvas",
|
to: "/admin/pro-canvas",
|
||||||
@@ -181,7 +181,7 @@ const Layout = () => {
|
|||||||
return [
|
return [
|
||||||
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/emp/fa/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
{ to: "/emp/fa/maps", icon: Map, label: "My Map" },
|
||||||
{ to: "/emp/fa/leads/new", icon: PlusCircle, label: "New Lead" },
|
{ to: "/emp/fa/leads", icon: ClipboardList, label: "Leads" },
|
||||||
{
|
{
|
||||||
to: "/emp/fa/pro-canvas",
|
to: "/emp/fa/pro-canvas",
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
|
|||||||
@@ -2735,6 +2735,7 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
const [projects, setProjects] = useState(MOCK_PROJECTS);
|
const [projects, setProjects] = useState(MOCK_PROJECTS);
|
||||||
const [orders, setOrders] = useState(MOCK_ORDERS);
|
const [orders, setOrders] = useState(MOCK_ORDERS);
|
||||||
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
const [vendorInvoices, setVendorInvoices] = useState(MOCK_VENDOR_INVOICES);
|
||||||
|
const [leads, setLeads] = useState([]);
|
||||||
|
|
||||||
// Initialize properties once
|
// Initialize properties once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2823,6 +2824,18 @@ export const MockStoreProvider = ({ children }) => {
|
|||||||
orders,
|
orders,
|
||||||
vendorInvoices,
|
vendorInvoices,
|
||||||
|
|
||||||
|
leads,
|
||||||
|
addLead: (lead) => {
|
||||||
|
const newLead = {
|
||||||
|
...lead,
|
||||||
|
id: `lead_${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
status: 'New',
|
||||||
|
};
|
||||||
|
setLeads(prev => [newLead, ...prev]);
|
||||||
|
return newLead;
|
||||||
|
},
|
||||||
|
|
||||||
updatePropertyStatus,
|
updatePropertyStatus,
|
||||||
addMeeting,
|
addMeeting,
|
||||||
updateUser: (updatedUser) => {
|
updateUser: (updatedUser) => {
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTheme } from '../context/ThemeContext';
|
import { useTheme } from '../context/ThemeContext';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { useMockStore } from '../data/mockStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
User, MapPin, Briefcase, Shield, Users,
|
User, MapPin, Briefcase, Shield, Users,
|
||||||
ChevronLeft, Zap, FileText, Save,
|
ChevronLeft, Zap, FileText, Save, AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
|
import LeadSectionWrapper from '../components/leads/LeadSectionWrapper';
|
||||||
import LeadJobSection from '../components/leads/LeadJobSection';
|
import LeadJobSection from '../components/leads/LeadJobSection';
|
||||||
@@ -103,8 +105,10 @@ const INITIAL_FORM = {
|
|||||||
export default function CreateLeadPage() {
|
export default function CreateLeadPage() {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { addLead } = useMockStore();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [validationError, setValidationError] = useState('');
|
||||||
|
|
||||||
const [isQuickCapture, setIsQuickCapture] = useState(true);
|
const [isQuickCapture, setIsQuickCapture] = useState(true);
|
||||||
const [openSections, setOpenSections] = useState({
|
const [openSections, setOpenSections] = useState({
|
||||||
@@ -159,6 +163,45 @@ export default function CreateLeadPage() {
|
|||||||
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
|
setOpenSections(prev => ({ ...prev, [id]: !prev[id] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// Required field validation
|
||||||
|
const missingFields = [];
|
||||||
|
if (!formData.firstName) missingFields.push('First Name');
|
||||||
|
if (!formData.lastName) missingFields.push('Last Name');
|
||||||
|
if (!formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10))
|
||||||
|
missingFields.push('Phone Number');
|
||||||
|
if (!formData.address) missingFields.push('Street Address');
|
||||||
|
if (!formData.city) missingFields.push('City');
|
||||||
|
if (!formData.leadSource) missingFields.push('Lead Source');
|
||||||
|
|
||||||
|
if (missingFields.length > 0) {
|
||||||
|
setValidationError(`Required: ${missingFields.join(', ')}`);
|
||||||
|
// Open the first incomplete section
|
||||||
|
if (!formData.firstName || !formData.lastName || !formData.phones.some(p => p.number.replace(/\D/g, '').length >= 10)) {
|
||||||
|
setOpenSections(prev => ({ ...prev, contact: true }));
|
||||||
|
} else if (!formData.address || !formData.city) {
|
||||||
|
setOpenSections(prev => ({ ...prev, property: true }));
|
||||||
|
} else {
|
||||||
|
setOpenSections(prev => ({ ...prev, job: true }));
|
||||||
|
}
|
||||||
|
toast.error(`Missing required fields: ${missingFields.join(', ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError('');
|
||||||
|
const savedLead = addLead({
|
||||||
|
...formData,
|
||||||
|
createdBy: user?.id,
|
||||||
|
createdByName: user?.name,
|
||||||
|
isQuickCapture,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Lead saved — ${formData.firstName} ${formData.lastName}`, {
|
||||||
|
description: formData.address || 'No address provided',
|
||||||
|
});
|
||||||
|
navigate('/emp/fa/leads');
|
||||||
|
};
|
||||||
|
|
||||||
const visibleSections = isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS;
|
const visibleSections = isQuickCapture ? SECTIONS.filter(s => s.quickCapture) : SECTIONS;
|
||||||
|
|
||||||
const modeSwitchTo = (quick) => {
|
const modeSwitchTo = (quick) => {
|
||||||
@@ -240,7 +283,7 @@ export default function CreateLeadPage() {
|
|||||||
<div className="mb-7">
|
<div className="mb-7">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(-1)}
|
onClick={() => navigate('/emp/fa/leads')}
|
||||||
className="flex items-center gap-1 text-xs uppercase tracking-widest font-bold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 mb-3 transition-colors"
|
className="flex items-center gap-1 text-xs uppercase tracking-widest font-bold text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300 mb-3 transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={13} strokeWidth={2.5} />
|
<ChevronLeft size={13} strokeWidth={2.5} />
|
||||||
@@ -388,6 +431,7 @@ export default function CreateLeadPage() {
|
|||||||
{/* Save */}
|
{/* Save */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97]"
|
className="flex-1 flex items-center justify-center gap-2 py-3 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97]"
|
||||||
style={{ backgroundColor: '#3B82F6' }}
|
style={{ backgroundColor: '#3B82F6' }}
|
||||||
>
|
>
|
||||||
@@ -396,6 +440,45 @@ export default function CreateLeadPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ----------------------------------------------------------------
|
||||||
|
Desktop save bar (hidden on mobile — mobile uses sticky footer)
|
||||||
|
---------------------------------------------------------------- */}
|
||||||
|
<div className={`hidden md:block fixed bottom-0 left-0 right-0 px-6 py-4
|
||||||
|
border-t backdrop-blur-md
|
||||||
|
${isDark ? 'bg-zinc-900/90 border-white/10' : 'bg-white/90 border-zinc-200'}
|
||||||
|
`}>
|
||||||
|
<div className="max-w-3xl mx-auto flex items-center justify-between gap-4">
|
||||||
|
{/* Validation error */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{validationError && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: -8 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -8 }}
|
||||||
|
className="flex items-center gap-2 text-sm text-red-500 dark:text-red-400"
|
||||||
|
>
|
||||||
|
<AlertCircle size={14} />
|
||||||
|
{validationError}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<span className="text-xs text-zinc-400 dark:text-zinc-500 uppercase tracking-widest font-bold">
|
||||||
|
{progress}% complete
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90"
|
||||||
|
style={{ backgroundColor: '#3B82F6' }}
|
||||||
|
>
|
||||||
|
<Save size={14} />
|
||||||
|
Save Lead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useTheme } from '../context/ThemeContext';
|
||||||
|
import { useMockStore } from '../data/mockStore';
|
||||||
|
import {
|
||||||
|
Plus, Search, MapPin, Phone, Zap, FileText,
|
||||||
|
Clock, User, ChevronRight, Filter,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Urgency badge
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const URGENCY_CONFIG = {
|
||||||
|
standard: { label: 'Standard', bg: 'bg-zinc-200 dark:bg-zinc-700', text: 'text-zinc-600 dark:text-zinc-300' },
|
||||||
|
high: { label: 'High', bg: 'bg-amber-100 dark:bg-amber-500/20', text: 'text-amber-700 dark:text-amber-400' },
|
||||||
|
emergency: { label: 'Emergency', bg: 'bg-red-100 dark:bg-red-500/20', text: 'text-red-700 dark:text-red-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
New: { bg: 'bg-blue-100 dark:bg-blue-500/20', text: 'text-blue-700 dark:text-blue-400' },
|
||||||
|
Contacted: { bg: 'bg-purple-100 dark:bg-purple-500/20', text: 'text-purple-700 dark:text-purple-400' },
|
||||||
|
Appointed: { bg: 'bg-emerald-100 dark:bg-emerald-500/20', text: 'text-emerald-700 dark:text-emerald-400' },
|
||||||
|
Closed: { bg: 'bg-zinc-100 dark:bg-zinc-700', text: 'text-zinc-500 dark:text-zinc-400' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function UrgencyBadge({ urgency }) {
|
||||||
|
const cfg = URGENCY_CONFIG[urgency] ?? URGENCY_CONFIG.standard;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
|
||||||
|
{cfg.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const cfg = STATUS_CONFIG[status] ?? STATUS_CONFIG.New;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${cfg.bg} ${cfg.text}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Relative time formatter
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function timeAgo(isoString) {
|
||||||
|
const diff = Math.floor((Date.now() - new Date(isoString)) / 1000);
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lead Card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function LeadCard({ lead, index }) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
const primaryPhone = lead.phones?.find(p => p.isPrimary) ?? lead.phones?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 280, damping: 28, delay: index * 0.04 }}
|
||||||
|
className={`rounded-2xl border p-4 flex items-start gap-4 cursor-pointer transition-shadow duration-200 group
|
||||||
|
${isDark
|
||||||
|
? 'bg-zinc-900/70 backdrop-blur-xl border-white/[0.08] hover:border-white/20 shadow-[0_4px_16px_rgba(0,0,0,0.3)]'
|
||||||
|
: 'bg-white border-zinc-200/60 shadow-sm hover:shadow-md'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 rounded-xl flex items-center justify-center text-sm font-black uppercase text-white shrink-0"
|
||||||
|
style={{ backgroundColor: '#3B82F6' }}
|
||||||
|
>
|
||||||
|
{(lead.firstName?.[0] ?? '?')}{(lead.lastName?.[0] ?? '')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-semibold text-sm text-zinc-900 dark:text-white">
|
||||||
|
{lead.firstName} {lead.lastName}
|
||||||
|
</span>
|
||||||
|
<UrgencyBadge urgency={lead.urgency} />
|
||||||
|
<StatusBadge status={lead.status} />
|
||||||
|
{lead.isQuickCapture && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-zinc-100 dark:bg-zinc-800 text-zinc-400">
|
||||||
|
<Zap size={8} />
|
||||||
|
Quick
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{(lead.address || lead.city) && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<MapPin size={10} className="shrink-0" />
|
||||||
|
{[lead.address, lead.city, lead.state].filter(Boolean).join(', ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{primaryPhone?.number && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<Phone size={10} className="shrink-0" />
|
||||||
|
{primaryPhone.number}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{lead.leadSource && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
|
||||||
|
<FileText size={10} className="shrink-0" />
|
||||||
|
{lead.leadSource}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-zinc-400 dark:text-zinc-500">
|
||||||
|
<Clock size={10} />
|
||||||
|
{timeAgo(lead.createdAt)}
|
||||||
|
</span>
|
||||||
|
{lead.createdByName && (
|
||||||
|
<span className="flex items-center gap-1 text-[11px] text-zinc-400 dark:text-zinc-500">
|
||||||
|
<User size={10} />
|
||||||
|
{lead.createdByName.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ChevronRight
|
||||||
|
size={14}
|
||||||
|
className="text-zinc-300 dark:text-zinc-600 group-hover:text-zinc-500 dark:group-hover:text-zinc-400 transition-colors mt-auto"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Empty state
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function EmptyState({ isDark, onNewLead }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ type: 'spring', stiffness: 280, damping: 28 }}
|
||||||
|
className={`rounded-2xl border-2 border-dashed px-8 py-16 flex flex-col items-center justify-center gap-4 text-center
|
||||||
|
${isDark ? 'border-zinc-800' : 'border-zinc-200'}`}
|
||||||
|
>
|
||||||
|
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center
|
||||||
|
${isDark ? 'bg-zinc-800' : 'bg-zinc-100'}`}
|
||||||
|
>
|
||||||
|
<FileText size={24} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-xl text-zinc-900 dark:text-white">
|
||||||
|
No Leads Yet
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
Capture your first lead at the door or from the office.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onNewLead}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90"
|
||||||
|
style={{ backgroundColor: '#3B82F6' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
New Lead
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
export default function LeadsListPage() {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const { leads } = useMockStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const filtered = leads.filter(lead => {
|
||||||
|
if (!search.trim()) return true;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return (
|
||||||
|
`${lead.firstName} ${lead.lastName}`.toLowerCase().includes(q) ||
|
||||||
|
lead.address?.toLowerCase().includes(q) ||
|
||||||
|
lead.city?.toLowerCase().includes(q) ||
|
||||||
|
lead.leadSource?.toLowerCase().includes(q) ||
|
||||||
|
lead.status?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen transition-colors duration-300 ${isDark ? 'bg-[#09090b]' : 'bg-zinc-100/80'}`}>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
|
||||||
|
|
||||||
|
{/* --- Header --- */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="font-['Barlow_Condensed'] font-black uppercase tracking-widest text-4xl leading-none text-zinc-900 dark:text-white">
|
||||||
|
Leads
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
{leads.length} total lead{leads.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/emp/fa/leads/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl font-bold text-sm uppercase tracking-widest text-white transition-all active:scale-[0.97] hover:opacity-90 shrink-0"
|
||||||
|
style={{ backgroundColor: '#3B82F6' }}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
New Lead
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Search bar --- */}
|
||||||
|
{leads.length > 0 && (
|
||||||
|
<div className={`flex items-center rounded-xl border mb-4
|
||||||
|
bg-white dark:bg-zinc-800/60
|
||||||
|
border-zinc-200 dark:border-zinc-700
|
||||||
|
focus-within:border-blue-400 dark:focus-within:border-blue-500
|
||||||
|
focus-within:ring-2 focus-within:ring-blue-100 dark:focus-within:ring-blue-500/20
|
||||||
|
transition-all duration-150`}
|
||||||
|
>
|
||||||
|
<Search size={14} className="ml-4 shrink-0 text-zinc-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, address, source…"
|
||||||
|
className="flex-1 py-3 pl-3 pr-4 text-sm outline-none bg-transparent
|
||||||
|
text-zinc-800 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* --- List --- */}
|
||||||
|
{leads.length === 0 ? (
|
||||||
|
<EmptyState isDark={isDark} onNewLead={() => navigate('/emp/fa/leads/new')} />
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-sm text-zinc-400 dark:text-zinc-500">
|
||||||
|
No leads match "{search}"
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<AnimatePresence>
|
||||||
|
{filtered.map((lead, i) => (
|
||||||
|
<LeadCard key={lead.id} lead={lead} index={i} />
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user