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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user