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:
tony
2025-09-17 22:01:07 +10:00
parent 074a82bfb6
commit 2e1bb2e55e
4018 changed files with 7539 additions and 38906 deletions

View File

@@ -0,0 +1,112 @@
'use client'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { LeadStats } from '@/lib/db'
interface LeadsChartProps {
stats: LeadStats
}
export default function LeadsChart({ stats }: LeadsChartProps) {
// Fill in missing dates with 0 values for the last 30 days
const fillMissingDates = (data: { date: string; count: number }[]) => {
const filledData = []
const today = new Date()
for (let i = 29; i >= 0; i--) {
const date = new Date(today)
date.setDate(date.getDate() - i)
const dateStr = date.toISOString().split('T')[0] // YYYY-MM-DD format
const existingData = data.find(d => d.date === dateStr)
filledData.push({
date: dateStr,
count: existingData ? existingData.count : 0,
formattedDate: date.toLocaleDateString('en-AU', {
month: 'short',
day: 'numeric'
})
})
}
return filledData
}
const chartData = fillMissingDates(stats.by_date)
return (
<div className="dashboard-card p-chorus-lg mb-chorus-xxl">
<h3 className="text-xl font-semibold text-carbon-900 dark:text-white mb-chorus-lg">
Lead Trends (Last 30 Days)
</h3>
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={chartData}
margin={{
top: 20,
right: 30,
left: 20,
bottom: 20,
}}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e5e5e5"
className="opacity-30"
/>
<XAxis
dataKey="formattedDate"
stroke="#6b7280"
fontSize={12}
tick={{ fill: '#6b7280' }}
interval="preserveStartEnd"
/>
<YAxis
stroke="#6b7280"
fontSize={12}
tick={{ fill: '#6b7280' }}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: '#ffffff',
border: '1px solid #e5e7eb',
borderRadius: '8px',
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
}}
labelStyle={{ color: '#374151', fontWeight: 'bold' }}
itemStyle={{ color: '#8b5cf6' }}
formatter={(value: number) => [value, 'Leads']}
labelFormatter={(label: string) => `Date: ${label}`}
/>
<Line
type="monotone"
dataKey="count"
stroke="#8b5cf6"
strokeWidth={3}
dot={{
fill: '#8b5cf6',
strokeWidth: 2,
r: 4
}}
activeDot={{
r: 6,
stroke: '#8b5cf6',
strokeWidth: 2,
fill: '#ffffff'
}}
/>
</LineChart>
</ResponsiveContainer>
</div>
{chartData.length === 0 && (
<div className="flex items-center justify-center h-80 text-carbon-500 dark:text-carbon-400">
<p>No data available for the last 30 days</p>
</div>
)}
</div>
)
}

View 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>
)
}

View File

@@ -0,0 +1,80 @@
'use client'
import { Users, TrendingUp, Calendar, Clock } from 'lucide-react'
import { LeadStats } from '@/lib/db'
interface StatsCardsProps {
stats: LeadStats
}
export default function StatsCards({ stats }: StatsCardsProps) {
const cards = [
{
title: 'Total Leads',
value: stats.total_leads,
icon: Users,
color: 'mulberry',
},
{
title: 'Today',
value: stats.leads_today,
icon: Clock,
color: 'ocean',
},
{
title: 'This Week',
value: stats.leads_this_week,
icon: Calendar,
color: 'eucalyptus',
},
{
title: 'This Month',
value: stats.leads_this_month,
icon: TrendingUp,
color: 'coral',
},
]
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-chorus-lg mb-chorus-xxl">
{cards.map((card, index) => {
const Icon = card.icon
return (
<div
key={card.title}
className={`dashboard-card p-chorus-lg border-l-4 hover:shadow-xl transition-shadow duration-300 ${
card.color === 'mulberry' ? 'border-mulberry-500' :
card.color === 'ocean' ? 'border-ocean-500' :
card.color === 'eucalyptus' ? 'border-eucalyptus-500' :
'border-coral-500'
}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-carbon-600 dark:text-carbon-300 mb-1">
{card.title}
</p>
<p className="text-3xl font-bold text-carbon-900 dark:text-white">
{card.value.toLocaleString()}
</p>
</div>
<div className={`p-chorus-md rounded-lg ${
card.color === 'mulberry' ? 'bg-mulberry-100 dark:bg-mulberry-900' :
card.color === 'ocean' ? 'bg-ocean-100 dark:bg-ocean-900' :
card.color === 'eucalyptus' ? 'bg-eucalyptus-100 dark:bg-eucalyptus-900' :
'bg-coral-100 dark:bg-coral-900'
}`}>
<Icon className={`h-6 w-6 ${
card.color === 'mulberry' ? 'text-mulberry-600 dark:text-mulberry-300' :
card.color === 'ocean' ? 'text-ocean-600 dark:text-ocean-300' :
card.color === 'eucalyptus' ? 'text-eucalyptus-600 dark:text-eucalyptus-300' :
'text-coral-600 dark:text-coral-300'
}`} />
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import { useState, useEffect } from 'react'
import { Sun, Moon } from 'lucide-react'
export default function ThemeToggle() {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
// Check if user has a saved preference, otherwise default to light mode
const savedTheme = localStorage.getItem('theme')
const prefersDark = savedTheme === 'dark'
setIsDark(prefersDark)
if (prefersDark) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [])
const toggleTheme = () => {
const newTheme = !isDark
setIsDark(newTheme)
if (newTheme) {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
} else {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
}
}
return (
<button
onClick={toggleTheme}
className="fixed top-chorus-lg right-chorus-lg z-50 p-chorus-md bg-white dark:bg-carbon-900 border border-sand-300 dark:border-carbon-600 rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 ease-out hover:scale-105 active:scale-95"
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{isDark ? (
<Sun className="h-5 w-5 text-sand-600 dark:text-sand-400" />
) : (
<Moon className="h-5 w-5 text-carbon-600" />
)}
</button>
)
}