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,52 @@
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json ./
RUN npm ci
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build application
RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Install curl for health check before switching users
RUN apk add --no-cache curl
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3002
ENV PORT=3002
ENV HOSTNAME="0.0.0.0"
# Removed health check as it was causing container restarts
CMD ["node", "server.js"]

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server'
import { pool, Lead } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '50')
const sortBy = searchParams.get('sortBy') || 'created_at'
const sortOrder = searchParams.get('sortOrder') || 'DESC'
const search = searchParams.get('search') || ''
const offset = (page - 1) * limit
let whereClause = ''
let queryParams: any[] = [limit, offset]
if (search) {
whereClause = `WHERE (first_name ILIKE $3 OR last_name ILIKE $3 OR email ILIKE $3 OR company_name ILIKE $3)`
queryParams.push(`%${search}%`)
}
const validSortColumns = ['created_at', 'first_name', 'last_name', 'email', 'company_name', 'lead_source']
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'created_at'
const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'
const leadsQuery = `
SELECT * FROM leads
${whereClause}
ORDER BY ${sortColumn} ${order}
LIMIT $1 OFFSET $2
`
const countQuery = `SELECT COUNT(*) as total FROM leads ${whereClause}`
const [leadsResult, countResult] = await Promise.all([
pool.query(leadsQuery, queryParams),
pool.query(countQuery, search ? [`%${search}%`] : [])
])
const leads: Lead[] = leadsResult.rows
const total = parseInt(countResult.rows[0].total)
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
leads,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
})
} catch (error) {
console.error('Error fetching leads:', error)
return NextResponse.json(
{ error: 'Failed to fetch leads' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { pool, LeadStats } from '@/lib/db'
export async function GET() {
try {
const queries = [
// Total leads
'SELECT COUNT(*) as count FROM leads',
// Leads today
'SELECT COUNT(*) as count FROM leads WHERE created_at >= CURRENT_DATE',
// Leads this week
'SELECT COUNT(*) as count FROM leads WHERE created_at >= date_trunc(\'week\', CURRENT_DATE)',
// Leads this month
'SELECT COUNT(*) as count FROM leads WHERE created_at >= date_trunc(\'month\', CURRENT_DATE)',
// By source
'SELECT lead_source, COUNT(*) as count FROM leads GROUP BY lead_source ORDER BY count DESC',
// By date (last 30 days)
`SELECT
DATE(created_at) as date,
COUNT(*) as count
FROM leads
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date DESC`
]
const [
totalResult,
todayResult,
weekResult,
monthResult,
sourceResult,
dateResult
] = await Promise.all(queries.map(query => pool.query(query)))
const stats: LeadStats = {
total_leads: parseInt(totalResult.rows[0].count),
leads_today: parseInt(todayResult.rows[0].count),
leads_this_week: parseInt(weekResult.rows[0].count),
leads_this_month: parseInt(monthResult.rows[0].count),
by_source: sourceResult.rows.map(row => ({
lead_source: row.lead_source,
count: parseInt(row.count)
})),
by_date: dateResult.rows.map(row => ({
date: row.date,
count: parseInt(row.count)
}))
}
return NextResponse.json(stats)
} catch (error) {
console.error('Error fetching stats:', error)
return NextResponse.json(
{ error: 'Failed to fetch statistics' },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,39 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn-primary {
@apply bg-mulberry-600 hover:bg-mulberry-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 ease-out hover:scale-105 active:scale-95;
}
.btn-secondary {
@apply border-2 border-mulberry-600 text-mulberry-600 hover:bg-mulberry-600 hover:text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 ease-out hover:scale-105 active:scale-95;
}
.btn-danger {
@apply bg-coral-600 hover:bg-coral-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 ease-out hover:scale-105 active:scale-95;
}
.table-cell {
@apply px-chorus-lg py-chorus-md text-sm border-b border-sand-200 dark:border-carbon-700 text-carbon-900 dark:text-carbon-100;
}
.table-header {
@apply px-chorus-lg py-chorus-lg text-xs font-semibold uppercase tracking-wide text-carbon-700 dark:text-carbon-300 bg-sand-100 dark:bg-carbon-800 border-b border-sand-300 dark:border-carbon-600;
}
/* Enhanced card styling for light mode branding */
.dashboard-card {
@apply bg-white dark:bg-carbon-900 rounded-lg shadow-lg border border-sand-200 dark:border-carbon-700 backdrop-blur-sm;
}
/* Branded gradients */
.gradient-primary {
@apply bg-gradient-to-r from-mulberry-500 to-mulberry-700;
}
.gradient-accent {
@apply bg-gradient-to-r from-ocean-500 to-eucalyptus-500;
}
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from 'next'
import { Inter, Exo } from 'next/font/google'
import './globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
})
const exo = Exo({
subsets: ['latin'],
variable: '--font-exo',
display: 'swap',
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
})
export const metadata: Metadata = {
title: 'CHORUS Dashboard',
description: 'Lead management dashboard for CHORUS Services',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={`${inter.variable} ${exo.variable}`}>
<body className="font-sans antialiased bg-white dark:bg-carbon-950 text-carbon-950 dark:text-white">
{children}
</body>
</html>
)
}

View File

@@ -0,0 +1,135 @@
'use client'
import { useState, useEffect } from 'react'
import { LeadStats, Lead } from '@/lib/db'
import StatsCards from '@/components/StatsCards'
import LeadsTable from '@/components/LeadsTable'
import ThemeToggle from '@/components/ThemeToggle'
import LeadsChart from '@/components/LeadsChart'
export default function DashboardPage() {
const [stats, setStats] = useState<LeadStats | null>(null)
const [leadsData, setLeadsData] = useState<{ leads: Lead[], pagination: any } | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = async () => {
try {
const [statsResponse, leadsResponse] = await Promise.all([
fetch('/api/stats'),
fetch('/api/leads')
])
if (!statsResponse.ok || !leadsResponse.ok) {
throw new Error('Failed to fetch data')
}
const statsData = await statsResponse.json()
const leadsData = await leadsResponse.json()
setStats(statsData)
setLeadsData(leadsData)
} catch (error) {
console.error('Failed to fetch dashboard data:', error)
setError('Failed to load dashboard data. Please try again.')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) {
return (
<main className="min-h-screen p-chorus-lg flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-mulberry-600 mx-auto mb-4"></div>
<p className="text-lg text-carbon-600 dark:text-carbon-300">Loading dashboard...</p>
</div>
</main>
)
}
if (error) {
return (
<main className="min-h-screen p-chorus-lg flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-coral-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="btn-primary px-chorus-lg py-chorus-md"
>
Retry
</button>
</div>
</main>
)
}
if (!stats || !leadsData) {
return (
<main className="min-h-screen p-chorus-lg flex items-center justify-center">
<p className="text-lg text-carbon-600 dark:text-carbon-300">No data available</p>
</main>
)
}
return (
<main className="min-h-screen p-chorus-lg bg-gradient-to-b from-white via-sand-50 to-sand-100 dark:from-carbon-950 dark:via-carbon-900 dark:to-carbon-950">
<div className="max-w-7xl mx-auto">
{/* Theme Toggle */}
<ThemeToggle />
{/* Header */}
<div className="mb-chorus-xxl">
<h1 className="text-h2 font-logo font-thin text-carbon-950 dark:text-white mb-chorus-md">
CHORUS Dashboard
</h1>
<p className="text-lg text-carbon-600 dark:text-carbon-300">
Lead management and analytics for CHORUS Services
</p>
</div>
{/* Stats Cards */}
<StatsCards stats={stats} />
{/* Lead Trends Chart */}
<LeadsChart stats={stats} />
{/* Source Breakdown */}
{stats.by_source.length > 0 && (
<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 Sources
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-chorus-md">
{stats.by_source.map((source) => (
<div
key={source.lead_source}
className="bg-sand-100 dark:bg-carbon-800 rounded-lg p-chorus-md border border-sand-200 dark:border-carbon-700 hover:shadow-md transition-shadow duration-200"
>
<div className="flex justify-between items-center">
<span className="text-sm font-medium text-carbon-700 dark:text-carbon-200">
{source.lead_source.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())}
</span>
<span className="text-lg font-bold text-carbon-900 dark:text-white">
{source.count}
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Leads Table */}
<LeadsTable
initialLeads={leadsData.leads}
initialPagination={leadsData.pagination}
/>
</div>
</main>
)
}

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

View File

@@ -0,0 +1,40 @@
import { Pool } from 'pg'
const pool = new Pool({
host: process.env.POSTGRES_HOST || 'chorus-db',
port: parseInt(process.env.POSTGRES_PORT || '5432'),
database: process.env.POSTGRES_DB || 'chorus_teaser',
user: process.env.POSTGRES_USER || 'chorus_admin',
password: process.env.POSTGRES_PASSWORD || 'chorus_secure_password_123',
})
export { pool }
export interface Lead {
id: number
first_name: string
last_name: string
email: string
company_name?: string
company_role?: string
lead_source: string
inquiry_details?: string
custom_message?: string
ip_address: string
user_agent?: string
country_code?: string
gdpr_consent_given: boolean
gdpr_consent_date?: Date
marketing_consent: boolean
created_at: Date
updated_at: Date
}
export interface LeadStats {
total_leads: number
leads_today: number
leads_this_week: number
leads_this_month: number
by_source: { lead_source: string; count: number }[]
by_date: { date: string; count: number }[]
}

View File

@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
// Simple basic auth for dashboard protection
const authHeader = request.headers.get('authorization')
if (!authHeader) {
return new NextResponse(null, {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="CHORUS Dashboard"',
},
})
}
const credentials = authHeader.split(' ')[1]
const [username, password] = Buffer.from(credentials, 'base64').toString().split(':')
// Simple hardcoded credentials - in production, use environment variables
const validUsername = process.env.DASHBOARD_USERNAME || 'chorus'
const validPassword = process.env.DASHBOARD_PASSWORD || 'services2025!'
if (username !== validUsername || password !== validPassword) {
return new NextResponse(null, {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="CHORUS Dashboard"',
},
})
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

View File

@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
serverExternalPackages: ['pg']
}
module.exports = nextConfig

2984
modules/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "chorus-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev -p 3002",
"build": "next build",
"start": "next start -p 3002",
"lint": "next lint"
},
"dependencies": {
"next": "15.5.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"pg": "^8.11.3",
"lucide-react": "^0.263.1",
"@types/pg": "^8.10.7",
"tailwindcss": "^3.3.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"recharts": "^2.8.0"
},
"devDependencies": {
"typescript": "^5.1.6",
"@types/node": "^20.4.7",
"@types/react": "^18.2.17",
"@types/react-dom": "^18.2.7"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1 @@
# This file ensures the public directory exists

View File

@@ -0,0 +1,161 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
colors: {
// CHORUS 8-Color Brand System
carbon: {
50: '#f6f6f6',
100: '#e7e7e7',
200: '#d1d1d1',
300: '#b0b0b0',
400: '#888888',
500: '#6d6d6d',
600: '#5d5d5d',
700: '#4f4f4f',
800: '#454545',
900: '#3d3d3d',
950: '#262626',
},
mulberry: {
50: '#faf7fc',
100: '#f3ecf8',
200: '#e9ddf2',
300: '#d8c2e7',
400: '#c19bd8',
500: '#a875c6',
600: '#8f58ab',
700: '#774591',
800: '#643b77',
900: '#543262',
950: '#371840',
},
walnut: {
50: '#f7f4f2',
100: '#ede6e0',
200: '#ddcdc1',
300: '#c7ab97',
400: '#b08970',
500: '#9f7557',
600: '#92634b',
700: '#795140',
800: '#624237',
900: '#51372f',
950: '#2b1c17',
},
nickel: {
50: '#f6f7f6',
100: '#e3e5e3',
200: '#c6cac7',
300: '#a1a8a3',
400: '#798179',
500: '#5e675f',
600: '#4a524b',
700: '#3d423e',
800: '#333733',
900: '#2b2f2c',
950: '#161816',
},
ocean: {
50: '#f0f8ff',
100: '#e0f2fe',
200: '#b9e6fe',
300: '#7cd3fc',
400: '#36bef8',
500: '#0ba5e9',
600: '#0284c7',
700: '#0369a1',
800: '#075985',
900: '#0c4a6e',
950: '#082f49',
},
eucalyptus: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
950: '#052e16',
},
sand: {
50: '#fefcf3',
100: '#fdf8e4',
200: '#fbf0c4',
300: '#f7e49b',
400: '#f2d66f',
500: '#eec64f',
600: '#deb042',
700: '#b9923a',
800: '#957438',
900: '#795f32',
950: '#443318',
},
coral: {
50: '#fef7f2',
100: '#feede2',
200: '#fcd8c0',
300: '#f9ba93',
400: '#f59564',
500: '#f17741',
600: '#e25d27',
700: '#bc481d',
800: '#963c1c',
900: '#79341c',
950: '#42180b',
},
},
spacing: {
'chorus-xs': '0.25rem',
'chorus-sm': '0.5rem',
'chorus-md': '1rem',
'chorus-lg': '1.5rem',
'chorus-xl': '2rem',
'chorus-xxl': '3rem',
'chorus-xxxl': '4rem',
},
fontFamily: {
'logo': ['var(--font-exo)', 'system-ui', 'sans-serif'],
'sans': ['var(--font-inter)', 'system-ui', 'sans-serif'],
},
fontSize: {
'h1': '4rem',
'h2': '3rem',
'h3': '2.25rem',
'h4': '1.875rem',
'h5': '1.5rem',
'h6': '1.25rem',
},
animation: {
'fade-in': 'fadeIn 0.6s ease-out forwards',
'fade-in-up': 'fadeInUp 0.6s ease-out forwards',
'slide-up': 'slideUp 0.6s ease-out forwards',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(30px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(50px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}