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:
52
modules/dashboard/Dockerfile
Normal file
52
modules/dashboard/Dockerfile
Normal 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"]
|
||||
63
modules/dashboard/app/api/leads/route.ts
Normal file
63
modules/dashboard/app/api/leads/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
64
modules/dashboard/app/api/stats/route.ts
Normal file
64
modules/dashboard/app/api/stats/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
modules/dashboard/app/globals.css
Normal file
39
modules/dashboard/app/globals.css
Normal 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;
|
||||
}
|
||||
}
|
||||
36
modules/dashboard/app/layout.tsx
Normal file
36
modules/dashboard/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
135
modules/dashboard/app/page.tsx
Normal file
135
modules/dashboard/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
modules/dashboard/components/LeadsChart.tsx
Normal file
112
modules/dashboard/components/LeadsChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
80
modules/dashboard/components/StatsCards.tsx
Normal file
80
modules/dashboard/components/StatsCards.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
49
modules/dashboard/components/ThemeToggle.tsx
Normal file
49
modules/dashboard/components/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
modules/dashboard/lib/db.ts
Normal file
40
modules/dashboard/lib/db.ts
Normal 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 }[]
|
||||
}
|
||||
37
modules/dashboard/middleware.ts
Normal file
37
modules/dashboard/middleware.ts
Normal 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).*)'],
|
||||
}
|
||||
7
modules/dashboard/next.config.js
Normal file
7
modules/dashboard/next.config.js
Normal 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
2984
modules/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
modules/dashboard/package.json
Normal file
29
modules/dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
modules/dashboard/postcss.config.js
Normal file
6
modules/dashboard/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
modules/dashboard/public/.gitkeep
Normal file
1
modules/dashboard/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# This file ensures the public directory exists
|
||||
161
modules/dashboard/tailwind.config.js
Normal file
161
modules/dashboard/tailwind.config.js
Normal 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: [],
|
||||
}
|
||||
28
modules/dashboard/tsconfig.json
Normal file
28
modules/dashboard/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user