Major update to chorus.services platform
- Extensive updates to system configuration and deployment - Enhanced documentation and architecture improvements - Updated dependencies and build configurations - Improved service integrations and workflows 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
390
modules/dashboard/components/LeadsTable.tsx
Normal file
390
modules/dashboard/components/LeadsTable.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, ChevronLeft, ChevronRight, ArrowUpDown, Eye } from 'lucide-react'
|
||||
import { Lead } from '@/lib/db'
|
||||
|
||||
interface LeadsTableProps {
|
||||
initialLeads: Lead[]
|
||||
initialPagination: any
|
||||
}
|
||||
|
||||
export default function LeadsTable({ initialLeads, initialPagination }: LeadsTableProps) {
|
||||
const [leads, setLeads] = useState<Lead[]>(initialLeads)
|
||||
const [pagination, setPagination] = useState(initialPagination)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [sortBy, setSortBy] = useState('created_at')
|
||||
const [sortOrder, setSortOrder] = useState('DESC')
|
||||
const [selectedLead, setSelectedLead] = useState<Lead | null>(null)
|
||||
|
||||
const fetchLeads = async (params: { page?: number; search?: string; sortBy?: string; sortOrder?: string } = {}) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
page: (params.page || pagination.page).toString(),
|
||||
search: params.search ?? search,
|
||||
sortBy: params.sortBy || sortBy,
|
||||
sortOrder: params.sortOrder || sortOrder,
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/leads?${searchParams}`)
|
||||
const data = await response.json()
|
||||
|
||||
setLeads(data.leads)
|
||||
setPagination(data.pagination)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch leads:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSort = (column: string) => {
|
||||
const newOrder = sortBy === column && sortOrder === 'ASC' ? 'DESC' : 'ASC'
|
||||
setSortBy(column)
|
||||
setSortOrder(newOrder)
|
||||
fetchLeads({ sortBy: column, sortOrder: newOrder })
|
||||
}
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
fetchLeads({ page: 1, search })
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-AU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-chorus-lg border-b border-sand-200 dark:border-carbon-700">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-chorus-md">
|
||||
<h3 className="text-xl font-semibold text-carbon-900 dark:text-white">
|
||||
Lead Management
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSearch} className="flex gap-chorus-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-carbon-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search leads..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 border border-sand-300 dark:border-carbon-600 rounded-lg bg-white dark:bg-carbon-800 text-carbon-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-mulberry-500 focus:border-mulberry-500 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary px-chorus-md py-2 text-sm"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-sand-100 dark:bg-carbon-800">
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('created_at')}
|
||||
className="flex items-center gap-1 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
Date <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('first_name')}
|
||||
className="flex items-center gap-1 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
Name <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('email')}
|
||||
className="flex items-center gap-1 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
Email <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('company_name')}
|
||||
className="flex items-center gap-1 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
Company <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('lead_source')}
|
||||
className="flex items-center gap-1 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
Source <ArrowUpDown className="h-3 w-3" />
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="table-cell text-center py-chorus-xxl">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : leads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="table-cell text-center py-chorus-xxl text-carbon-500">
|
||||
No leads found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
leads.map((lead) => (
|
||||
<tr key={lead.id} className="hover:bg-sand-100 dark:hover:bg-carbon-800 transition-colors">
|
||||
<td className="table-cell">
|
||||
{formatDate(lead.created_at.toString())}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div>
|
||||
<div className="font-medium text-carbon-900 dark:text-white">
|
||||
{lead.first_name} {lead.last_name}
|
||||
</div>
|
||||
{lead.company_role && (
|
||||
<div className="text-xs text-carbon-500 dark:text-carbon-400">
|
||||
{lead.company_role}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<a
|
||||
href={`mailto:${lead.email}`}
|
||||
className="text-ocean-600 dark:text-ocean-400 hover:underline"
|
||||
>
|
||||
{lead.email}
|
||||
</a>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
{lead.company_name || '-'}
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
lead.lead_source === 'request_early_access'
|
||||
? 'bg-mulberry-100 text-mulberry-800 dark:bg-mulberry-900 dark:text-mulberry-200'
|
||||
: 'bg-ocean-100 text-ocean-800 dark:bg-ocean-900 dark:text-ocean-200'
|
||||
}`}>
|
||||
{lead.lead_source.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<button
|
||||
onClick={() => setSelectedLead(lead)}
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="p-chorus-lg border-t border-sand-200 dark:border-carbon-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-carbon-600 dark:text-carbon-300">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of{' '}
|
||||
{pagination.total} results
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-chorus-sm">
|
||||
<button
|
||||
onClick={() => fetchLeads({ page: pagination.page - 1 })}
|
||||
disabled={!pagination.hasPrev || loading}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm border border-sand-300 dark:border-carbon-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-sand-100 dark:hover:bg-carbon-800 transition-colors text-carbon-700 dark:text-carbon-200"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<span className="text-sm text-carbon-600 dark:text-carbon-300">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => fetchLeads({ page: pagination.page + 1 })}
|
||||
disabled={!pagination.hasNext || loading}
|
||||
className="flex items-center gap-1 px-3 py-2 text-sm border border-sand-300 dark:border-carbon-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-sand-100 dark:hover:bg-carbon-800 transition-colors text-carbon-700 dark:text-carbon-200"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lead Detail Modal */}
|
||||
{selectedLead && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-chorus-lg z-50">
|
||||
<div className="bg-white dark:bg-carbon-900 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-chorus-lg border-b border-sand-200 dark:border-carbon-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-xl font-semibold text-carbon-900 dark:text-white">
|
||||
Lead Details
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setSelectedLead(null)}
|
||||
className="text-carbon-600 dark:text-carbon-300 hover:text-carbon-900 dark:hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-chorus-lg space-y-chorus-md">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-md">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{selectedLead.first_name} {selectedLead.last_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{selectedLead.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLead.company_name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Company
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{selectedLead.company_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLead.company_role && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{selectedLead.company_role}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Lead Source
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{selectedLead.lead_source.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Date
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white">
|
||||
{formatDate(selectedLead.created_at.toString())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedLead.inquiry_details && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Inquiry Details
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white whitespace-pre-wrap">
|
||||
{selectedLead.inquiry_details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLead.custom_message && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
|
||||
Custom Message
|
||||
</label>
|
||||
<p className="text-carbon-900 dark:text-white whitespace-pre-wrap">
|
||||
{selectedLead.custom_message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-chorus-md text-sm">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-carbon-500 dark:text-carbon-400 mb-1">
|
||||
IP Address
|
||||
</label>
|
||||
<p className="text-carbon-700 dark:text-carbon-200">
|
||||
{selectedLead.ip_address}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedLead.country_code && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-carbon-500 dark:text-carbon-400 mb-1">
|
||||
Country
|
||||
</label>
|
||||
<p className="text-carbon-700 dark:text-carbon-200">
|
||||
{selectedLead.country_code}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-carbon-500 dark:text-carbon-400 mb-1">
|
||||
GDPR Consent
|
||||
</label>
|
||||
<p className={`${selectedLead.gdpr_consent_given ? 'text-eucalyptus-600' : 'text-coral-600'}`}>
|
||||
{selectedLead.gdpr_consent_given ? 'Given' : 'Not Given'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-carbon-500 dark:text-carbon-400 mb-1">
|
||||
Marketing Consent
|
||||
</label>
|
||||
<p className={`${selectedLead.marketing_consent ? 'text-eucalyptus-600' : 'text-coral-600'}`}>
|
||||
{selectedLead.marketing_consent ? 'Given' : 'Not Given'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user