feat: dark mode for all pages, calendar view for events
This commit is contained in:
77
src/App.tsx
77
src/App.tsx
@@ -1,22 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, lazy, Suspense } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import Layout from '@/components/Layout';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import DashboardPage from '@/pages/DashboardPage';
|
||||
import ClientsPage from '@/pages/ClientsPage';
|
||||
import ClientDetailPage from '@/pages/ClientDetailPage';
|
||||
import EventsPage from '@/pages/EventsPage';
|
||||
import EmailsPage from '@/pages/EmailsPage';
|
||||
import SettingsPage from '@/pages/SettingsPage';
|
||||
import AdminPage from '@/pages/AdminPage';
|
||||
import NetworkPage from '@/pages/NetworkPage';
|
||||
import ReportsPage from '@/pages/ReportsPage';
|
||||
import InvitePage from '@/pages/InvitePage';
|
||||
import ForgotPasswordPage from '@/pages/ForgotPasswordPage';
|
||||
import ResetPasswordPage from '@/pages/ResetPasswordPage';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
const LoginPage = lazy(() => import('@/pages/LoginPage'));
|
||||
const DashboardPage = lazy(() => import('@/pages/DashboardPage'));
|
||||
const ClientsPage = lazy(() => import('@/pages/ClientsPage'));
|
||||
const ClientDetailPage = lazy(() => import('@/pages/ClientDetailPage'));
|
||||
const EventsPage = lazy(() => import('@/pages/EventsPage'));
|
||||
const EmailsPage = lazy(() => import('@/pages/EmailsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/SettingsPage'));
|
||||
const AdminPage = lazy(() => import('@/pages/AdminPage'));
|
||||
const NetworkPage = lazy(() => import('@/pages/NetworkPage'));
|
||||
const ReportsPage = lazy(() => import('@/pages/ReportsPage'));
|
||||
const InvitePage = lazy(() => import('@/pages/InvitePage'));
|
||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'));
|
||||
const ResetPasswordPage = lazy(() => import('@/pages/ResetPasswordPage'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
if (isLoading) return <PageLoader />;
|
||||
@@ -33,29 +34,31 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||
} />
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="emails" element={<EmailsPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Routes>
|
||||
<Route path="/login" element={
|
||||
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||
} />
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||
<Route path="/reset-password/:token" element={<ResetPasswordPage />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="clients" element={<ClientsPage />} />
|
||||
<Route path="clients/:id" element={<ClientDetailPage />} />
|
||||
<Route path="events" element={<EventsPage />} />
|
||||
<Route path="emails" element={<EmailsPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="admin" element={<AdminPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: 'bg-blue-50 text-blue-700 border-blue-200',
|
||||
green: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
yellow: 'bg-amber-50 text-amber-700 border-amber-200',
|
||||
red: 'bg-red-50 text-red-700 border-red-200',
|
||||
purple: 'bg-purple-50 text-purple-700 border-purple-200',
|
||||
gray: 'bg-slate-50 text-slate-600 border-slate-200',
|
||||
pink: 'bg-pink-50 text-pink-700 border-pink-200',
|
||||
blue: 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-900/50 dark:text-blue-300 dark:border-blue-800',
|
||||
green: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-900/50 dark:text-emerald-300 dark:border-emerald-800',
|
||||
yellow: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-900/50 dark:text-amber-300 dark:border-amber-800',
|
||||
red: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-900/50 dark:text-red-300 dark:border-red-800',
|
||||
purple: 'bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-900/50 dark:text-purple-300 dark:border-purple-800',
|
||||
gray: 'bg-slate-50 text-slate-600 border-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:border-slate-600',
|
||||
pink: 'bg-pink-50 text-pink-700 border-pink-200 dark:bg-pink-900/50 dark:text-pink-300 dark:border-pink-800',
|
||||
};
|
||||
|
||||
interface BadgeProps {
|
||||
@@ -26,7 +26,7 @@ export default function Badge({ children, color = 'gray', className, onClick, ac
|
||||
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||
colorMap[color] || colorMap.gray,
|
||||
onClick && 'cursor-pointer hover:opacity-80',
|
||||
active && 'ring-2 ring-blue-400 ring-offset-1',
|
||||
active && 'ring-2 ring-blue-400 ring-offset-1 dark:ring-offset-slate-900',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -125,13 +125,13 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="border-2 border-dashed border-slate-300 rounded-xl p-8 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50/50 transition-all"
|
||||
className="border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-xl p-8 text-center cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 hover:bg-blue-50/50 dark:hover:bg-blue-900/20 transition-all"
|
||||
>
|
||||
<Upload className="w-10 h-10 text-slate-400 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
<Upload className="w-10 h-10 text-slate-400 dark:text-slate-500 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
Click to select a CSV file
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-1">
|
||||
Must include at least First Name and Last Name columns
|
||||
</p>
|
||||
</div>
|
||||
@@ -145,11 +145,11 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
<LoadingSpinner size="sm" />
|
||||
<span className="text-sm text-slate-500">Parsing CSV...</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Parsing CSV...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
@@ -160,25 +160,25 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
{/* Step: Column Mapping */}
|
||||
{step === 'mapping' && preview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
<span><strong>{preview.totalRows}</strong> rows found in <strong>{file?.name}</strong></span>
|
||||
</div>
|
||||
|
||||
{/* Column mapping */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-slate-700">Map columns to client fields:</h4>
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">Map columns to client fields:</h4>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{preview.headers.map((header, index) => (
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<span className="text-sm text-slate-600 w-40 truncate flex-shrink-0" title={header}>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300 w-40 truncate flex-shrink-0" title={header}>
|
||||
{header}
|
||||
</span>
|
||||
<ArrowRight className="w-4 h-4 text-slate-300 flex-shrink-0" />
|
||||
<ArrowRight className="w-4 h-4 text-slate-300 dark:text-slate-600 flex-shrink-0" />
|
||||
<select
|
||||
value={mapping[index] || ''}
|
||||
onChange={(e) => updateMapping(index, e.target.value)}
|
||||
className="flex-1 px-3 py-1.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="flex-1 px-3 py-1.5 border border-slate-200 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
{CLIENT_FIELDS.map((f) => (
|
||||
<option key={f.value} value={f.value}>{f.label}</option>
|
||||
@@ -192,27 +192,27 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
{/* Preview table */}
|
||||
{preview.sampleRows.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-700 mb-2">Preview (first {preview.sampleRows.length} rows):</h4>
|
||||
<div className="overflow-x-auto border border-slate-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Preview (first {preview.sampleRows.length} rows):</h4>
|
||||
<div className="overflow-x-auto border border-slate-200 dark:border-slate-700 rounded-lg">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-50">
|
||||
<tr className="bg-slate-50 dark:bg-slate-700">
|
||||
{preview.headers.map((h, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-600 whitespace-nowrap">
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-600 dark:text-slate-300 whitespace-nowrap">
|
||||
{mapping[i] ? (
|
||||
<span className="text-blue-600">{CLIENT_FIELDS.find(f => f.value === mapping[i])?.label || mapping[i]}</span>
|
||||
<span className="text-blue-600 dark:text-blue-400">{CLIENT_FIELDS.find(f => f.value === mapping[i])?.label || mapping[i]}</span>
|
||||
) : (
|
||||
<span className="text-slate-400 line-through">{h}</span>
|
||||
<span className="text-slate-400 dark:text-slate-500 line-through">{h}</span>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
<tbody className="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{preview.sampleRows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className={`px-3 py-2 whitespace-nowrap ${mapping[ci] ? 'text-slate-700' : 'text-slate-300'}`}>
|
||||
<td key={ci} className={`px-3 py-2 whitespace-nowrap ${mapping[ci] ? 'text-slate-700 dark:text-slate-300' : 'text-slate-300 dark:text-slate-600'}`}>
|
||||
{cell || '—'}
|
||||
</td>
|
||||
))}
|
||||
@@ -225,21 +225,21 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
)}
|
||||
|
||||
{!hasRequiredFields() && (
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 text-amber-700 rounded-lg text-sm">
|
||||
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
You must map both "First Name" and "Last Name" columns
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-700 rounded-lg text-sm">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between pt-2">
|
||||
<button onClick={reset} className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 transition-colors">
|
||||
<button onClick={reset} className="px-4 py-2 text-sm text-slate-600 dark:text-slate-300 hover:text-slate-800 dark:hover:text-slate-100 transition-colors">
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
@@ -257,19 +257,19 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
{step === 'importing' && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="text-sm text-slate-600">Importing clients...</p>
|
||||
<p className="text-xs text-slate-400">This may take a moment for large files</p>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">Importing clients...</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">This may take a moment for large files</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step: Results */}
|
||||
{step === 'results' && result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-emerald-50 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-600 flex-shrink-0" />
|
||||
<div className="flex items-center gap-3 p-4 bg-emerald-50 dark:bg-emerald-900/30 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-600 dark:text-emerald-400 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-emerald-800">Import Complete</p>
|
||||
<p className="text-sm text-emerald-700">
|
||||
<p className="font-semibold text-emerald-800 dark:text-emerald-300">Import Complete</p>
|
||||
<p className="text-sm text-emerald-700 dark:text-emerald-400">
|
||||
Successfully imported <strong>{result.imported}</strong> client{result.imported !== 1 ? 's' : ''}
|
||||
{result.skipped > 0 && `, ${result.skipped} skipped`}
|
||||
</p>
|
||||
@@ -278,10 +278,10 @@ export default function CSVImportModal({ isOpen, onClose, onComplete }: CSVImpor
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-slate-700">Issues ({result.errors.length}):</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1 p-3 bg-slate-50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">Issues ({result.errors.length}):</h4>
|
||||
<div className="max-h-40 overflow-y-auto space-y-1 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||
{result.errors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-slate-600">{err}</p>
|
||||
<p key={i} className="text-xs text-slate-600 dark:text-slate-300">{err}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,7 +55,6 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Clean empty strings
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(form).filter(([_, v]) => v !== '' && v !== undefined && !(Array.isArray(v) && v.length === 0))
|
||||
) as ClientCreate;
|
||||
@@ -65,8 +64,8 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
await onSubmit(cleaned);
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
const labelClass = 'block text-sm font-medium text-slate-700 mb-1';
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
const labelClass = 'block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1';
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
@@ -153,15 +152,15 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
placeholder="Add tag..."
|
||||
className={inputClass}
|
||||
/>
|
||||
<button type="button" onClick={addTag} className="px-3 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors">
|
||||
<Plus className="w-4 h-4 text-slate-600" />
|
||||
<button type="button" onClick={addTag} className="px-3 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors">
|
||||
<Plus className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{form.tags?.map((tag) => (
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 text-blue-700 rounded-full text-xs">
|
||||
<span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-blue-50 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-full text-xs">
|
||||
{tag}
|
||||
<button type="button" onClick={() => removeTag(tag)} className="hover:text-blue-900"><X className="w-3 h-3" /></button>
|
||||
<button type="button" onClick={() => removeTag(tag)} className="hover:text-blue-900 dark:hover:text-blue-100"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -178,15 +177,15 @@ export default function ClientForm({ initialData, onSubmit, loading }: ClientFor
|
||||
placeholder="Add interest..."
|
||||
className={inputClass}
|
||||
/>
|
||||
<button type="button" onClick={addInterest} className="px-3 py-2 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors">
|
||||
<Plus className="w-4 h-4 text-slate-600" />
|
||||
<button type="button" onClick={addInterest} className="px-3 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors">
|
||||
<Plus className="w-4 h-4 text-slate-600 dark:text-slate-300" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{form.interests?.map((i) => (
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 text-emerald-700 rounded-full text-xs">
|
||||
<span key={i} className="inline-flex items-center gap-1 px-2.5 py-1 bg-emerald-50 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 rounded-full text-xs">
|
||||
{i}
|
||||
<button type="button" onClick={() => removeInterest(i)} className="hover:text-emerald-900"><X className="w-3 h-3" /></button>
|
||||
<button type="button" onClick={() => removeInterest(i)} className="hover:text-emerald-900 dark:hover:text-emerald-100"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -77,27 +77,29 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
setEditContent('');
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={() => { onClose(); reset(); }} title={`Email for ${clientName}`} size="lg">
|
||||
{!generated ? (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Purpose / Context</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Purpose / Context</label>
|
||||
<textarea
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="e.g., Follow up after our coffee meeting about the marketing proposal..."
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">AI Provider</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">AI Provider</label>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value as any)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
@@ -116,7 +118,7 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
<button
|
||||
onClick={handleGenerateBirthday}
|
||||
disabled={isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-pink-50 text-pink-700 rounded-lg text-sm font-medium hover:bg-pink-100 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-pink-50 dark:bg-pink-900/30 text-pink-700 dark:text-pink-300 rounded-lg text-sm font-medium hover:bg-pink-100 dark:hover:bg-pink-900/50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Gift className="w-4 h-4" />
|
||||
Birthday
|
||||
@@ -126,30 +128,30 @@ export default function EmailComposeModal({ isOpen, onClose, clientId, clientNam
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Subject</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Subject</label>
|
||||
<input
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className={inputClass}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Content</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Content</label>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
className={`${inputClass} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={reset} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg text-sm font-medium transition-colors">
|
||||
<button onClick={reset} className="px-4 py-2 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg text-sm font-medium transition-colors">
|
||||
New Email
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 disabled:opacity-50 transition-colors"
|
||||
className="px-4 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Draft'}
|
||||
</button>
|
||||
|
||||
@@ -13,11 +13,11 @@ interface EmptyStateProps {
|
||||
export default function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-slate-400" />
|
||||
<div className="w-16 h-16 bg-slate-100 dark:bg-slate-700 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-500 max-w-sm mb-6">{description}</p>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-1">{title}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mb-6">{description}</p>
|
||||
{action && (
|
||||
<button
|
||||
onClick={action.onClick}
|
||||
|
||||
@@ -31,16 +31,16 @@ const typeIcons: Record<string, typeof Calendar> = {
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
overdue: 'text-red-500 bg-red-50',
|
||||
upcoming: 'text-blue-500 bg-blue-50',
|
||||
stale: 'text-amber-500 bg-amber-50',
|
||||
drafts: 'text-purple-500 bg-purple-50',
|
||||
overdue: 'text-red-500 bg-red-50 dark:bg-red-900/30',
|
||||
upcoming: 'text-blue-500 bg-blue-50 dark:bg-blue-900/30',
|
||||
stale: 'text-amber-500 bg-amber-50 dark:bg-amber-900/30',
|
||||
drafts: 'text-purple-500 bg-purple-50 dark:bg-purple-900/30',
|
||||
};
|
||||
|
||||
const priorityDot: Record<string, string> = {
|
||||
high: 'bg-red-500',
|
||||
medium: 'bg-amber-400',
|
||||
low: 'bg-slate-300',
|
||||
low: 'bg-slate-300 dark:bg-slate-500',
|
||||
};
|
||||
|
||||
export default function NotificationBell() {
|
||||
@@ -52,12 +52,10 @@ export default function NotificationBell() {
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotifications();
|
||||
// Refresh every 5 minutes
|
||||
const interval = setInterval(fetchNotifications, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
@@ -92,7 +90,7 @@ export default function NotificationBell() {
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
'relative p-2 rounded-lg transition-colors',
|
||||
open ? 'bg-slate-100 text-slate-700' : 'text-slate-400 hover:bg-slate-100 hover:text-slate-600'
|
||||
open ? 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-200' : 'text-slate-400 dark:text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-700 hover:text-slate-600 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
@@ -107,13 +105,13 @@ export default function NotificationBell() {
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white rounded-xl border border-slate-200 shadow-xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||
<h3 className="text-sm font-semibold text-slate-800">Notifications</h3>
|
||||
<div className="absolute right-0 top-full mt-2 w-80 sm:w-96 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-xl z-50 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100 dark:border-slate-700">
|
||||
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">Notifications</h3>
|
||||
{counts && (
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 dark:text-slate-500">
|
||||
{counts.overdue > 0 && (
|
||||
<span className="text-red-500 font-medium">{counts.overdue} overdue</span>
|
||||
<span className="text-red-500 dark:text-red-400 font-medium">{counts.overdue} overdue</span>
|
||||
)}
|
||||
{counts.upcoming > 0 && (
|
||||
<span>{counts.upcoming} upcoming</span>
|
||||
@@ -124,7 +122,7 @@ export default function NotificationBell() {
|
||||
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
{visibleNotifs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-slate-400 dark:text-slate-500">
|
||||
<Clock className="w-8 h-8 mb-2" />
|
||||
<p className="text-sm">All caught up!</p>
|
||||
</div>
|
||||
@@ -132,20 +130,20 @@ export default function NotificationBell() {
|
||||
visibleNotifs.map(n => {
|
||||
const Icon = typeIcons[n.type] || Calendar;
|
||||
return (
|
||||
<div key={n.id} className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 border-b border-slate-50 last:border-0">
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 text-slate-500')}>
|
||||
<div key={n.id} className="flex items-start gap-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 border-b border-slate-50 dark:border-slate-700 last:border-0">
|
||||
<div className={cn('p-1.5 rounded-lg mt-0.5', typeColors[n.type] || 'bg-slate-50 dark:bg-slate-700 text-slate-500')}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<Link to={n.link} onClick={() => setOpen(false)} className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className={cn('w-1.5 h-1.5 rounded-full flex-shrink-0', priorityDot[n.priority])} />
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{n.title}</p>
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">{n.title}</p>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">{n.description}</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{n.description}</p>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => dismiss(n.id)}
|
||||
className="p-1 rounded text-slate-300 hover:text-slate-500 hover:bg-slate-100 flex-shrink-0"
|
||||
className="p-1 rounded text-slate-300 dark:text-slate-500 hover:text-slate-500 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-600 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@@ -156,11 +154,11 @@ export default function NotificationBell() {
|
||||
</div>
|
||||
|
||||
{visibleNotifs.length > 0 && (
|
||||
<div className="border-t border-slate-100 px-4 py-2.5">
|
||||
<div className="border-t border-slate-100 dark:border-slate-700 px-4 py-2.5">
|
||||
<Link
|
||||
to="/reports"
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-xs text-blue-600 hover:text-blue-700 font-medium"
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
View Reports →
|
||||
</Link>
|
||||
|
||||
@@ -137,24 +137,24 @@ export default function AdminPage() {
|
||||
if (currentUser?.role !== 'admin') {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-500 font-medium">Access denied. Admin only.</p>
|
||||
<p className="text-red-500 dark:text-red-400 font-medium">Access denied. Admin only.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">Admin</h1>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-6">Admin</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-4 border-b border-slate-200 mb-6">
|
||||
<div className="flex gap-4 border-b border-slate-200 dark:border-slate-700 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('users')}
|
||||
className={cn(
|
||||
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === 'users'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
<Users className="w-4 h-4 inline mr-2" />
|
||||
@@ -165,8 +165,8 @@ export default function AdminPage() {
|
||||
className={cn(
|
||||
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
|
||||
activeTab === 'invites'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400 dark:border-blue-400'
|
||||
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
<Mail className="w-4 h-4 inline mr-2" />
|
||||
@@ -175,12 +175,12 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-slate-500">Loading...</div>
|
||||
<div className="text-center py-12 text-slate-500 dark:text-slate-400">Loading...</div>
|
||||
) : activeTab === 'users' ? (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-500">
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700 text-left text-sm text-slate-500 dark:text-slate-400">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Email</th>
|
||||
<th className="px-4 py-3 font-medium">Role</th>
|
||||
@@ -193,23 +193,23 @@ export default function AdminPage() {
|
||||
<tr
|
||||
key={user.id}
|
||||
onClick={() => setSelectedUser(user)}
|
||||
className="border-b border-slate-100 last:border-0 cursor-pointer hover:bg-slate-50 transition-colors"
|
||||
className="border-b border-slate-100 dark:border-slate-700 last:border-0 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-900">{user.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{user.email}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-900 dark:text-slate-100">{user.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
'px-2 py-1 text-xs rounded-full',
|
||||
user.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-700'
|
||||
user.role === 'admin' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||||
)}>
|
||||
{user.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500">
|
||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{user.createdAt ? new Date(user.createdAt).toLocaleDateString() : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<ChevronRight className="w-4 h-4 text-slate-400" />
|
||||
<ChevronRight className="w-4 h-4 text-slate-400 dark:text-slate-500" />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -228,29 +228,29 @@ export default function AdminPage() {
|
||||
Invite User
|
||||
</button>
|
||||
) : (
|
||||
<div className="mb-6 p-4 bg-slate-50 rounded-xl border border-slate-200">
|
||||
<h3 className="font-medium text-slate-900 mb-4">Invite New User</h3>
|
||||
<div className="mb-6 p-4 bg-slate-50 dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700">
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100 mb-4">Invite New User</h3>
|
||||
|
||||
{inviteUrl ? (
|
||||
<div>
|
||||
<p className="text-sm text-green-600 mb-2">✓ Invite created! Share this link:</p>
|
||||
<p className="text-sm text-green-600 dark:text-green-400 mb-2">✓ Invite created! Share this link:</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={inviteUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-slate-300 rounded-lg text-sm bg-white"
|
||||
className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyUrl}
|
||||
className="px-3 py-2 border border-slate-300 rounded-lg hover:bg-slate-100 transition-colors"
|
||||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-green-600" /> : <Copy className="w-4 h-4 text-slate-600" />}
|
||||
{copied ? <Check className="w-4 h-4 text-green-600 dark:text-green-400" /> : <Copy className="w-4 h-4 text-slate-600 dark:text-slate-300" />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={resetInviteForm}
|
||||
className="mt-3 text-sm text-slate-500 hover:text-slate-700"
|
||||
className="mt-3 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
Create another invite
|
||||
</button>
|
||||
@@ -258,37 +258,37 @@ export default function AdminPage() {
|
||||
) : (
|
||||
<form onSubmit={handleCreateInvite} className="space-y-4">
|
||||
{inviteError && (
|
||||
<p className="text-sm text-red-500">{inviteError}</p>
|
||||
<p className="text-sm text-red-500 dark:text-red-400">{inviteError}</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Name</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inviteName}
|
||||
onChange={(e) => setInviteName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Email</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="john@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Role</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Role</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'admin' | 'user')}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
@@ -305,7 +305,7 @@ export default function AdminPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetInviteForm}
|
||||
className="px-4 py-2 border border-slate-300 text-sm font-medium rounded-lg hover:bg-slate-100 transition-colors"
|
||||
className="px-4 py-2 border border-slate-300 dark:border-slate-600 text-sm font-medium text-slate-700 dark:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -316,10 +316,10 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{/* Invites list */}
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-x-auto">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-x-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-500">
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700 text-left text-sm text-slate-500 dark:text-slate-400">
|
||||
<th className="px-4 py-3 font-medium">Name</th>
|
||||
<th className="px-4 py-3 font-medium">Email</th>
|
||||
<th className="px-4 py-3 font-medium">Role</th>
|
||||
@@ -331,19 +331,19 @@ export default function AdminPage() {
|
||||
<tbody>
|
||||
{invites.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-slate-500 dark:text-slate-400">
|
||||
No invites yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
invites.map((invite) => (
|
||||
<tr key={invite.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-900">{invite.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{invite.email}</td>
|
||||
<tr key={invite.id} className="border-b border-slate-100 dark:border-slate-700 last:border-0">
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-900 dark:text-slate-100">{invite.name}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-300">{invite.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
'px-2 py-1 text-xs rounded-full',
|
||||
invite.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-700'
|
||||
invite.role === 'admin' ? 'bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300' : 'bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300'
|
||||
)}>
|
||||
{invite.role}
|
||||
</span>
|
||||
@@ -351,21 +351,21 @@ export default function AdminPage() {
|
||||
<td className="px-4 py-3">
|
||||
<span className={cn(
|
||||
'px-2 py-1 text-xs rounded-full',
|
||||
invite.status === 'accepted' ? 'bg-green-100 text-green-700' :
|
||||
invite.status === 'expired' ? 'bg-red-100 text-red-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
invite.status === 'accepted' ? 'bg-green-100 dark:bg-green-900/50 text-green-700 dark:text-green-300' :
|
||||
invite.status === 'expired' ? 'bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300' :
|
||||
'bg-yellow-100 dark:bg-yellow-900/50 text-yellow-700 dark:text-yellow-300'
|
||||
)}>
|
||||
{invite.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-500">
|
||||
<td className="px-4 py-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{new Date(invite.expiresAt).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{invite.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(invite.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-500 transition-colors"
|
||||
className="p-1 text-slate-400 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -385,17 +385,17 @@ export default function AdminPage() {
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40"
|
||||
className="fixed inset-0 bg-black/20 dark:bg-black/50 z-40"
|
||||
onClick={closeUserPanel}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div className="fixed inset-y-0 right-0 w-full max-w-md bg-white shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
|
||||
<div className="fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-slate-800 shadow-xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200">
|
||||
<h2 className="text-lg font-semibold text-slate-900">User Settings</h2>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">User Settings</h2>
|
||||
<button
|
||||
onClick={closeUserPanel}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 transition-colors"
|
||||
className="p-1 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
@@ -405,33 +405,33 @@ export default function AdminPage() {
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* User info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 flex items-center justify-center text-blue-600 font-semibold text-lg">
|
||||
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/50 flex items-center justify-center text-blue-600 dark:text-blue-300 font-semibold text-lg">
|
||||
{selectedUser.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-slate-900">{selectedUser.name}</h3>
|
||||
<p className="text-sm text-slate-500">{selectedUser.email}</p>
|
||||
<h3 className="font-medium text-slate-900 dark:text-slate-100">{selectedUser.name}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{selectedUser.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Joined</label>
|
||||
<p className="text-sm text-slate-600">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Joined</label>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{selectedUser.createdAt ? new Date(selectedUser.createdAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">Role</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">Role</label>
|
||||
{selectedUser.id === currentUser?.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-3 py-1.5 text-sm rounded-lg bg-purple-100 text-purple-700 font-medium">
|
||||
<span className="px-3 py-1.5 text-sm rounded-lg bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 font-medium">
|
||||
{selectedUser.role}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">(your account)</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">(your account)</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
@@ -440,8 +440,8 @@ export default function AdminPage() {
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||||
selectedUser.role === 'user'
|
||||
? 'bg-slate-100 border-slate-300 text-slate-900'
|
||||
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
? 'bg-slate-100 dark:bg-slate-700 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<Shield className="w-4 h-4" />
|
||||
@@ -452,8 +452,8 @@ export default function AdminPage() {
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium border transition-colors',
|
||||
selectedUser.role === 'admin'
|
||||
? 'bg-purple-100 border-purple-300 text-purple-700'
|
||||
: 'border-slate-200 text-slate-500 hover:bg-slate-50'
|
||||
? 'bg-purple-100 dark:bg-purple-900/50 border-purple-300 dark:border-purple-700 text-purple-700 dark:text-purple-300'
|
||||
: 'border-slate-200 dark:border-slate-600 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<ShieldAlert className="w-4 h-4" />
|
||||
@@ -466,24 +466,24 @@ export default function AdminPage() {
|
||||
|
||||
{/* Actions — only for non-self users */}
|
||||
{selectedUser.id !== currentUser?.id && (
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||||
<h4 className="text-sm font-medium text-slate-700">Actions</h4>
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200 dark:border-slate-700">
|
||||
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">Actions</h4>
|
||||
|
||||
{/* Password Reset */}
|
||||
<div className="bg-slate-50 rounded-lg p-4">
|
||||
<div className="bg-slate-50 dark:bg-slate-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<KeyRound className="w-4 h-4 text-slate-500" />
|
||||
<span className="text-sm font-medium text-slate-700">Password Reset</span>
|
||||
<KeyRound className="w-4 h-4 text-slate-500 dark:text-slate-400" />
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">Password Reset</span>
|
||||
</div>
|
||||
{resetUrl ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-green-600">✓ Reset link generated</p>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">✓ Reset link generated</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={resetUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-1.5 border border-slate-300 rounded-lg text-xs bg-white font-mono"
|
||||
className="flex-1 px-3 py-1.5 border border-slate-300 dark:border-slate-600 rounded-lg text-xs bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 font-mono"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopyResetUrl}
|
||||
@@ -498,7 +498,7 @@ export default function AdminPage() {
|
||||
<button
|
||||
onClick={() => handleGenerateResetLink(selectedUser.id)}
|
||||
disabled={resetLoading}
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium disabled:opacity-50"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium disabled:opacity-50"
|
||||
>
|
||||
{resetLoading ? 'Generating...' : 'Generate reset link'}
|
||||
</button>
|
||||
@@ -506,12 +506,12 @@ export default function AdminPage() {
|
||||
</div>
|
||||
|
||||
{/* Delete User */}
|
||||
<div className="bg-red-50 rounded-lg p-4">
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">Danger Zone</span>
|
||||
<Trash2 className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">Danger Zone</span>
|
||||
</div>
|
||||
<p className="text-xs text-red-600 mb-3">Permanently delete this user and all their data.</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mb-3">Permanently delete this user and all their data.</p>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(selectedUser.id)}
|
||||
className="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-lg hover:bg-red-700 transition-colors"
|
||||
|
||||
@@ -66,8 +66,8 @@ export default function EmailsPage() {
|
||||
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Emails</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">AI-generated emails for your network</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Emails</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">AI-generated emails for your network</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCompose(true)}
|
||||
@@ -87,8 +87,8 @@ export default function EmailsPage() {
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
statusFilter === key
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -107,22 +107,22 @@ export default function EmailsPage() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filtered.map((email) => (
|
||||
<div key={email.id} className="bg-white border border-slate-200 rounded-xl p-5">
|
||||
<div key={email.id} className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||
{editingEmail === email.id ? (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
value={editSubject}
|
||||
onChange={(e) => setEditSubject(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<textarea
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button onClick={() => setEditingEmail(null)} className="px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg">Cancel</button>
|
||||
<button onClick={() => setEditingEmail(null)} className="px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg">Cancel</button>
|
||||
<button onClick={saveEdit} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,28 +133,28 @@ export default function EmailsPage() {
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<EmailStatusBadge status={email.status} />
|
||||
{email.client && (
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
To: {email.client.firstName} {email.client.lastName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-slate-400">{formatDate(email.createdAt)}</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">{formatDate(email.createdAt)}</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-900 mb-2">{email.subject}</h3>
|
||||
<p className="text-sm text-slate-600 whitespace-pre-wrap line-clamp-4">{email.content}</p>
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-2">{email.subject}</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 whitespace-pre-wrap line-clamp-4">{email.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-slate-100">
|
||||
<div className="flex gap-2 mt-4 pt-3 border-t border-slate-100 dark:border-slate-700">
|
||||
{email.status === 'draft' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startEdit(email)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" /> Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => { await sendEmail(email.id); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg transition-colors"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded-lg transition-colors"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" /> Send
|
||||
</button>
|
||||
@@ -162,7 +162,7 @@ export default function EmailsPage() {
|
||||
)}
|
||||
<button
|
||||
onClick={async () => { if (confirm('Delete this email?')) await deleteEmail(email.id); }}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors ml-auto"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors ml-auto"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||
</button>
|
||||
@@ -178,11 +178,11 @@ export default function EmailsPage() {
|
||||
<Modal isOpen={showCompose} onClose={() => setShowCompose(false)} title="Generate Email" size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Client *</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client *</label>
|
||||
<select
|
||||
value={composeForm.clientId}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, clientId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select a client...</option>
|
||||
{clients.map((c) => (
|
||||
@@ -191,21 +191,21 @@ export default function EmailsPage() {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Purpose</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Purpose</label>
|
||||
<textarea
|
||||
value={composeForm.purpose}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, purpose: e.target.value })}
|
||||
rows={3}
|
||||
placeholder="What's this email about?"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Provider</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Provider</label>
|
||||
<select
|
||||
value={composeForm.provider}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, provider: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="anthropic">Anthropic (Claude)</option>
|
||||
<option value="openai">OpenAI (GPT)</option>
|
||||
@@ -223,7 +223,7 @@ export default function EmailsPage() {
|
||||
<button
|
||||
onClick={handleGenerateBirthday}
|
||||
disabled={!composeForm.clientId || isGenerating}
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-pink-50 text-pink-700 rounded-lg text-sm font-medium hover:bg-pink-100 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-2 px-4 py-2.5 bg-pink-50 dark:bg-pink-900/30 text-pink-700 dark:text-pink-300 rounded-lg text-sm font-medium hover:bg-pink-100 dark:hover:bg-pink-900/50 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Gift className="w-4 h-4" />
|
||||
Birthday
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEventsStore } from '@/stores/events';
|
||||
import { useClientsStore } from '@/stores/clients';
|
||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star } from 'lucide-react';
|
||||
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star, ChevronLeft, ChevronRight, List, Grid3X3 } from 'lucide-react';
|
||||
import { cn, formatDate, getDaysUntil } from '@/lib/utils';
|
||||
import Badge, { EventTypeBadge } from '@/components/Badge';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
import Modal from '@/components/Modal';
|
||||
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||
import type { EventCreate } from '@/types';
|
||||
import type { EventCreate, Event } from '@/types';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const eventTypes = [
|
||||
@@ -19,11 +19,179 @@ const eventTypes = [
|
||||
{ key: 'custom', label: 'Custom', icon: Star },
|
||||
];
|
||||
|
||||
const eventTypeColors: Record<string, { dot: string; bg: string; text: string }> = {
|
||||
birthday: { dot: 'bg-pink-500', bg: 'bg-pink-50 dark:bg-pink-900/30', text: 'text-pink-700 dark:text-pink-300' },
|
||||
anniversary: { dot: 'bg-purple-500', bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-300' },
|
||||
followup: { dot: 'bg-blue-500', bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-300' },
|
||||
custom: { dot: 'bg-slate-400', bg: 'bg-slate-50 dark:bg-slate-700', text: 'text-slate-700 dark:text-slate-300' },
|
||||
};
|
||||
|
||||
function getMonthDays(year: number, month: number) {
|
||||
const firstDay = new Date(year, month, 1).getDay();
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const daysInPrevMonth = new Date(year, month, 0).getDate();
|
||||
|
||||
const days: { date: number; month: 'prev' | 'current' | 'next'; fullDate: string }[] = [];
|
||||
|
||||
// Previous month padding
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
const d = daysInPrevMonth - i;
|
||||
const m = month === 0 ? 11 : month - 1;
|
||||
const y = month === 0 ? year - 1 : year;
|
||||
days.push({ date: d, month: 'prev', fullDate: `${y}-${String(m + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` });
|
||||
}
|
||||
|
||||
// Current month
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({ date: i, month: 'current', fullDate: `${year}-${String(month + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}` });
|
||||
}
|
||||
|
||||
// Next month padding
|
||||
const remaining = 42 - days.length;
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
const m = month === 11 ? 0 : month + 1;
|
||||
const y = month === 11 ? year + 1 : year;
|
||||
days.push({ date: i, month: 'next', fullDate: `${y}-${String(m + 1).padStart(2, '0')}-${String(i).padStart(2, '0')}` });
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
function CalendarView({ events, onSelectDate }: { events: Event[]; onSelectDate: (date: string, evts: Event[]) => void }) {
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
|
||||
const days = useMemo(() => getMonthDays(year, month), [year, month]);
|
||||
|
||||
// Map events to dates (using UTC date part or recurring)
|
||||
const eventsByDate = useMemo(() => {
|
||||
const map: Record<string, Event[]> = {};
|
||||
events.forEach((evt) => {
|
||||
const d = evt.date.split('T')[0];
|
||||
// For recurring events, also check same month/day in current year
|
||||
const evtDate = new Date(d);
|
||||
const keys = [d];
|
||||
if (evt.recurring) {
|
||||
const recurKey = `${year}-${String(month + 1).padStart(2, '0')}-${String(evtDate.getUTCDate()).padStart(2, '0')}`;
|
||||
if (!keys.includes(recurKey)) keys.push(recurKey);
|
||||
}
|
||||
keys.forEach((k) => {
|
||||
if (!map[k]) map[k] = [];
|
||||
if (!map[k].includes(evt)) map[k].push(evt);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [events, year, month]);
|
||||
|
||||
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 0) { setMonth(11); setYear(year - 1); }
|
||||
else setMonth(month - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (month === 11) { setMonth(0); setYear(year + 1); }
|
||||
else setMonth(month + 1);
|
||||
};
|
||||
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()); };
|
||||
|
||||
const monthName = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-200 dark:border-slate-700">
|
||||
<button onClick={prevMonth} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 transition-colors">
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{monthName}</h3>
|
||||
<button onClick={goToday} className="text-xs px-2 py-1 rounded bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600 transition-colors">
|
||||
Today
|
||||
</button>
|
||||
</div>
|
||||
<button onClick={nextMonth} className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 transition-colors">
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 border-b border-slate-200 dark:border-slate-700">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-500 dark:text-slate-400 py-2">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((day, i) => {
|
||||
const dayEvents = eventsByDate[day.fullDate] || [];
|
||||
const isToday = day.fullDate === todayStr;
|
||||
const isCurrentMonth = day.month === 'current';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => dayEvents.length > 0 && onSelectDate(day.fullDate, dayEvents)}
|
||||
className={cn(
|
||||
'relative h-20 sm:h-24 border-b border-r border-slate-100 dark:border-slate-700 p-1.5 text-left transition-colors',
|
||||
isCurrentMonth ? 'bg-white dark:bg-slate-800' : 'bg-slate-50/50 dark:bg-slate-800/50',
|
||||
dayEvents.length > 0 && 'cursor-pointer hover:bg-blue-50/50 dark:hover:bg-blue-900/20',
|
||||
!dayEvents.length && 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'inline-flex items-center justify-center w-7 h-7 rounded-full text-sm',
|
||||
isToday && 'bg-blue-600 text-white font-bold',
|
||||
!isToday && isCurrentMonth && 'text-slate-900 dark:text-slate-100',
|
||||
!isToday && !isCurrentMonth && 'text-slate-300 dark:text-slate-600',
|
||||
)}>
|
||||
{day.date}
|
||||
</span>
|
||||
{dayEvents.length > 0 && (
|
||||
<div className="flex flex-wrap gap-0.5 mt-0.5">
|
||||
{dayEvents.slice(0, 3).map((evt, ei) => {
|
||||
const colors = eventTypeColors[evt.type] || eventTypeColors.custom;
|
||||
return (
|
||||
<span key={ei} className={cn('block w-1.5 h-1.5 rounded-full', colors.dot)} title={evt.title} />
|
||||
);
|
||||
})}
|
||||
{dayEvents.length > 3 && (
|
||||
<span className="text-[9px] text-slate-400 dark:text-slate-500 ml-0.5">+{dayEvents.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Show first event title on larger screens */}
|
||||
{dayEvents.length > 0 && (
|
||||
<div className="hidden sm:block mt-0.5">
|
||||
{dayEvents.slice(0, 2).map((evt, ei) => {
|
||||
const colors = eventTypeColors[evt.type] || eventTypeColors.custom;
|
||||
return (
|
||||
<div key={ei} className={cn('text-[10px] leading-tight px-1 py-0.5 rounded truncate mb-0.5', colors.bg, colors.text)}>
|
||||
{evt.title}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function EventsPage() {
|
||||
const { events, isLoading, typeFilter, setTypeFilter, fetchEvents, createEvent, deleteEvent, syncAll } = useEventsStore();
|
||||
const { clients, fetchClients } = useClientsStore();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [viewMode, setViewMode] = useState<'list' | 'calendar'>('list');
|
||||
const [selectedDayEvents, setSelectedDayEvents] = useState<{ date: string; events: Event[] } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
@@ -53,14 +221,41 @@ export default function EventsPage() {
|
||||
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Events</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">{events.length} events tracked</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Events</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">{events.length} events tracked</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* View toggle */}
|
||||
<div className="flex rounded-lg border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors',
|
||||
viewMode === 'list'
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
List
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('calendar')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 text-sm font-medium transition-colors border-l border-slate-200 dark:border-slate-700',
|
||||
viewMode === 'calendar'
|
||||
? 'bg-slate-100 dark:bg-slate-700 text-slate-900 dark:text-slate-100'
|
||||
: 'bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
Calendar
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSync}
|
||||
disabled={syncing}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-100 dark:bg-slate-700 text-slate-700 dark:text-slate-300 rounded-lg text-sm font-medium hover:bg-slate-200 dark:hover:bg-slate-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', syncing && 'animate-spin')} />
|
||||
Sync All
|
||||
@@ -84,8 +279,8 @@ export default function EventsPage() {
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
typeFilter === key
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
? 'bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
@@ -94,62 +289,120 @@ export default function EventsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Events list */}
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No events"
|
||||
description={typeFilter ? 'No events of this type' : 'Create your first event or sync from clients'}
|
||||
action={{ label: 'Create Event', onClick: () => setShowCreate(true) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||
{sorted.map((event) => {
|
||||
const days = getDaysUntil(event.date);
|
||||
return (
|
||||
<div key={event.id} className="flex items-center gap-4 px-5 py-4">
|
||||
<div className={cn(
|
||||
'w-12 h-12 rounded-xl flex flex-col items-center justify-center text-xs font-semibold',
|
||||
days <= 1 ? 'bg-red-50 text-red-600' :
|
||||
days <= 7 ? 'bg-amber-50 text-amber-600' :
|
||||
'bg-slate-50 text-slate-600'
|
||||
)}>
|
||||
<span className="text-lg leading-none">{new Date(event.date).getUTCDate()}</span>
|
||||
<span className="text-[10px] uppercase">{new Date(event.date).toLocaleString('en', { month: 'short' })}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900">{event.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<EventTypeBadge type={event.type} />
|
||||
{event.recurring && <span className="text-xs text-slate-400">Recurring</span>}
|
||||
{event.client && (
|
||||
<Link to={`/clients/${event.clientId}`} className="text-xs text-blue-600 hover:underline">
|
||||
{event.client.firstName} {event.client.lastName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
days <= 1 ? 'text-red-600' : days <= 7 ? 'text-amber-600' : 'text-slate-500'
|
||||
)}>
|
||||
{days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : `${days} days`}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">{formatDate(event.date)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Delete this event?')) await deleteEvent(event.id);
|
||||
}}
|
||||
className="p-1.5 rounded text-slate-300 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/* Calendar View */}
|
||||
{viewMode === 'calendar' ? (
|
||||
<>
|
||||
<CalendarView
|
||||
events={filtered}
|
||||
onSelectDate={(date, evts) => setSelectedDayEvents({ date, events: evts })}
|
||||
/>
|
||||
{/* Day detail modal */}
|
||||
<Modal
|
||||
isOpen={!!selectedDayEvents}
|
||||
onClose={() => setSelectedDayEvents(null)}
|
||||
title={selectedDayEvents ? `Events on ${formatDate(selectedDayEvents.date)}` : ''}
|
||||
>
|
||||
{selectedDayEvents && (
|
||||
<div className="space-y-3">
|
||||
{selectedDayEvents.events.map((event) => {
|
||||
const days = getDaysUntil(event.date);
|
||||
return (
|
||||
<div key={event.id} className="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700 rounded-lg">
|
||||
<div className={cn(
|
||||
'w-2 h-2 rounded-full flex-shrink-0',
|
||||
(eventTypeColors[event.type] || eventTypeColors.custom).dot
|
||||
)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{event.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<EventTypeBadge type={event.type} />
|
||||
{event.client && (
|
||||
<Link to={`/clients/${event.clientId}`} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{event.client.firstName} {event.client.lastName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Delete this event?')) {
|
||||
await deleteEvent(event.id);
|
||||
setSelectedDayEvents((prev) =>
|
||||
prev ? { ...prev, events: prev.events.filter((e) => e.id !== event.id) } : null
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="p-1.5 rounded text-slate-300 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
/* List View */
|
||||
<>
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Calendar}
|
||||
title="No events"
|
||||
description={typeFilter ? 'No events of this type' : 'Create your first event or sync from clients'}
|
||||
action={{ label: 'Create Event', onClick: () => setShowCreate(true) }}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl divide-y divide-slate-100 dark:divide-slate-700">
|
||||
{sorted.map((event) => {
|
||||
const days = getDaysUntil(event.date);
|
||||
return (
|
||||
<div key={event.id} className="flex items-center gap-4 px-5 py-4">
|
||||
<div className={cn(
|
||||
'w-12 h-12 rounded-xl flex flex-col items-center justify-center text-xs font-semibold',
|
||||
days <= 1 ? 'bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400' :
|
||||
days <= 7 ? 'bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400' :
|
||||
'bg-slate-50 dark:bg-slate-700 text-slate-600 dark:text-slate-300'
|
||||
)}>
|
||||
<span className="text-lg leading-none">{new Date(event.date).getUTCDate()}</span>
|
||||
<span className="text-[10px] uppercase">{new Date(event.date).toLocaleString('en', { month: 'short' })}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">{event.title}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<EventTypeBadge type={event.type} />
|
||||
{event.recurring && <span className="text-xs text-slate-400 dark:text-slate-500">Recurring</span>}
|
||||
{event.client && (
|
||||
<Link to={`/clients/${event.clientId}`} className="text-xs text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{event.client.firstName} {event.client.lastName}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={cn(
|
||||
'text-sm font-medium',
|
||||
days <= 1 ? 'text-red-600 dark:text-red-400' : days <= 7 ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'
|
||||
)}>
|
||||
{days === 0 ? 'Today' : days === 1 ? 'Tomorrow' : `${days} days`}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">{formatDate(event.date)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('Delete this event?')) await deleteEvent(event.id);
|
||||
}}
|
||||
className="p-1.5 rounded text-slate-300 dark:text-slate-500 hover:text-red-500 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
@@ -195,18 +448,18 @@ function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
const inputClass = 'w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Create Event">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Title *</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Title *</label>
|
||||
<input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Type</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Type</label>
|
||||
<select value={form.type} onChange={(e) => setForm({ ...form, type: e.target.value as any })} className={inputClass}>
|
||||
<option value="custom">Custom</option>
|
||||
<option value="birthday">Birthday</option>
|
||||
@@ -215,12 +468,12 @@ function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Date *</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Date *</label>
|
||||
<input type="date" required value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">Client</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">Client</label>
|
||||
<select value={form.clientId} onChange={(e) => setForm({ ...form, clientId: e.target.value })} className={inputClass}>
|
||||
<option value="">None</option>
|
||||
{clients.map((c) => (
|
||||
@@ -229,22 +482,22 @@ function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.recurring}
|
||||
onChange={(e) => setForm({ ...form, recurring: e.target.checked })}
|
||||
className="rounded border-slate-300"
|
||||
className="rounded border-slate-300 dark:border-slate-600"
|
||||
/>
|
||||
Recurring annually
|
||||
</label>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
|
||||
<label>Remind</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.reminderDays || ''}
|
||||
onChange={(e) => setForm({ ...form, reminderDays: Number(e.target.value) || undefined })}
|
||||
className="w-16 px-2 py-1 border border-slate-300 rounded text-sm"
|
||||
className="w-16 px-2 py-1 border border-slate-300 dark:border-slate-600 rounded text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||
min={0}
|
||||
/>
|
||||
<span>days before</span>
|
||||
|
||||
@@ -26,15 +26,15 @@ export default function ForgotPasswordPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Forgot Password</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Forgot Password</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">
|
||||
{submitted
|
||||
? 'Check your email or contact your admin'
|
||||
: 'Enter your email to request a password reset'}
|
||||
@@ -42,17 +42,17 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||
{submitted ? (
|
||||
<div className="text-center">
|
||||
<div className="p-4 bg-green-50 border border-green-200 rounded-lg mb-6">
|
||||
<p className="text-sm text-green-700">
|
||||
<div className="p-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg mb-6">
|
||||
<p className="text-sm text-green-700 dark:text-green-300">
|
||||
If an account with <strong>{email}</strong> exists, a password reset has been initiated. Contact your administrator for the reset link.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
className="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
@@ -61,19 +61,19 @@ export default function ForgotPasswordPage() {
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Email</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export default function ForgotPasswordPage() {
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
|
||||
className="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to login
|
||||
|
||||
@@ -51,11 +51,9 @@ export default function InvitePage() {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await api.acceptInvite(token!, password, name);
|
||||
// If we got a token, store it
|
||||
if (result.token) {
|
||||
api.setToken(result.token);
|
||||
}
|
||||
// Now log in with the credentials
|
||||
try {
|
||||
await api.login(invite!.email, password);
|
||||
} catch {
|
||||
@@ -72,7 +70,7 @@ export default function InvitePage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
@@ -80,58 +78,58 @@ export default function InvitePage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-red-500" />
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 dark:bg-red-900/50 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Invalid Invite</h1>
|
||||
<p className="text-slate-500">{error}</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">Invalid Invite</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Welcome to NetworkCRM</h1>
|
||||
<p className="text-slate-500 mt-1">You've been invited to join as <span className="font-medium text-slate-700">{invite?.role}</span></p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Welcome to NetworkCRM</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">You've been invited to join as <span className="font-medium text-slate-700 dark:text-slate-300">{invite?.role}</span></p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div className="mb-6 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-700">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||
<div className="mb-6 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Setting up account for <strong>{invite?.email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{submitError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm rounded-lg">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Name</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -139,13 +137,13 @@ export default function InvitePage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -153,13 +151,13 @@ export default function InvitePage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Confirm Password</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -9,19 +9,19 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
const categoryConfig: Record<string, { label: string; icon: typeof Network; color: string; bg: string }> = {
|
||||
industry: { label: 'Industry', icon: Building2, color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
interests: { label: 'Interests', icon: Heart, color: 'text-pink-600', bg: 'bg-pink-50' },
|
||||
location: { label: 'Location', icon: MapPin, color: 'text-emerald-600', bg: 'bg-emerald-50' },
|
||||
business: { label: 'Business', icon: Briefcase, color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
social: { label: 'Social', icon: Users, color: 'text-amber-600', bg: 'bg-amber-50' },
|
||||
const categoryConfig: Record<string, { label: string; icon: typeof Network; color: string; darkColor: string; bg: string; darkBg: string }> = {
|
||||
industry: { label: 'Industry', icon: Building2, color: 'text-blue-600', darkColor: 'dark:text-blue-400', bg: 'bg-blue-50', darkBg: 'dark:bg-blue-900/30' },
|
||||
interests: { label: 'Interests', icon: Heart, color: 'text-pink-600', darkColor: 'dark:text-pink-400', bg: 'bg-pink-50', darkBg: 'dark:bg-pink-900/30' },
|
||||
location: { label: 'Location', icon: MapPin, color: 'text-emerald-600', darkColor: 'dark:text-emerald-400', bg: 'bg-emerald-50', darkBg: 'dark:bg-emerald-900/30' },
|
||||
business: { label: 'Business', icon: Briefcase, color: 'text-purple-600', darkColor: 'dark:text-purple-400', bg: 'bg-purple-50', darkBg: 'dark:bg-purple-900/30' },
|
||||
social: { label: 'Social', icon: Users, color: 'text-amber-600', darkColor: 'dark:text-amber-400', bg: 'bg-amber-50', darkBg: 'dark:bg-amber-900/30' },
|
||||
};
|
||||
|
||||
function ScoreBadge({ score }: { score: number }) {
|
||||
const color = score >= 60 ? 'bg-emerald-100 text-emerald-700' :
|
||||
score >= 40 ? 'bg-blue-100 text-blue-700' :
|
||||
score >= 25 ? 'bg-amber-100 text-amber-700' :
|
||||
'bg-slate-100 text-slate-600';
|
||||
const color = score >= 60 ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300' :
|
||||
score >= 40 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300' :
|
||||
score >= 25 ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300' :
|
||||
'bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300';
|
||||
return (
|
||||
<span className={cn('px-2 py-0.5 rounded-full text-xs font-bold', color)}>
|
||||
{score}%
|
||||
@@ -38,10 +38,10 @@ function MatchCard({ match, onGenerateIntro, generatingIntro }: {
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition-shadow">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5 hover:shadow-md dark:hover:shadow-slate-900/50 transition-shadow">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className={cn('flex items-center gap-2 px-2.5 py-1 rounded-full text-xs font-medium', config.bg, config.color)}>
|
||||
<div className={cn('flex items-center gap-2 px-2.5 py-1 rounded-full text-xs font-medium', config.bg, config.darkBg, config.color, config.darkColor)}>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{config.label}
|
||||
</div>
|
||||
@@ -52,40 +52,40 @@ function MatchCard({ match, onGenerateIntro, generatingIntro }: {
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Link
|
||||
to={`/clients/${match.clientA.id}`}
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 transition-colors min-w-0"
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-w-0"
|
||||
>
|
||||
<div className="w-9 h-9 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||
<div className="w-9 h-9 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||
{match.clientA.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-900 truncate">{match.clientA.name}</span>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{match.clientA.name}</span>
|
||||
</Link>
|
||||
<div className="flex-shrink-0">
|
||||
<Network className="w-4 h-4 text-slate-300" />
|
||||
<Network className="w-4 h-4 text-slate-300 dark:text-slate-600" />
|
||||
</div>
|
||||
<Link
|
||||
to={`/clients/${match.clientB.id}`}
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 transition-colors min-w-0"
|
||||
className="flex-1 flex items-center gap-2 p-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors min-w-0"
|
||||
>
|
||||
<div className="w-9 h-9 bg-purple-100 text-purple-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||
<div className="w-9 h-9 bg-purple-100 dark:bg-purple-900/50 text-purple-700 dark:text-purple-300 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||
{match.clientB.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-900 truncate">{match.clientB.name}</span>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100 truncate">{match.clientB.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Reasons */}
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{match.reasons.map((reason, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-slate-600">
|
||||
<span className="text-emerald-500 mt-0.5 flex-shrink-0">✓</span>
|
||||
<div key={i} className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
<span className="text-emerald-500 dark:text-emerald-400 mt-0.5 flex-shrink-0">✓</span>
|
||||
{reason}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Intro suggestion */}
|
||||
<div className="bg-slate-50 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-slate-600 italic">{match.introSuggestion}</p>
|
||||
<div className="bg-slate-50 dark:bg-slate-700 rounded-lg p-3 mb-3">
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 italic">{match.introSuggestion}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -93,7 +93,7 @@ function MatchCard({ match, onGenerateIntro, generatingIntro }: {
|
||||
<button
|
||||
onClick={() => onGenerateIntro(match)}
|
||||
disabled={generatingIntro}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 text-blue-700 hover:bg-blue-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{generatingIntro ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@@ -165,8 +165,8 @@ export default function NetworkPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Network Matching</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Network Matching</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">
|
||||
AI-powered suggestions for connecting your clients
|
||||
</p>
|
||||
</div>
|
||||
@@ -175,33 +175,33 @@ export default function NetworkPage() {
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Users className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm text-slate-500">Clients</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Clients</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.totalClients}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.totalClients}</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Network className="w-4 h-4 text-purple-500" />
|
||||
<span className="text-sm text-slate-500">Matches Found</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Matches Found</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.totalMatches}</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.totalMatches}</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
||||
<span className="text-sm text-slate-500">Avg Score</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Avg Score</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-slate-900">{stats.avgScore}%</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.avgScore}%</p>
|
||||
</div>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Star className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm text-slate-500">Top Connector</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Top Connector</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-slate-900 truncate">
|
||||
<p className="text-lg font-bold text-slate-900 dark:text-slate-100 truncate">
|
||||
{stats.topConnectors[0]?.name || '—'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -210,20 +210,20 @@ export default function NetworkPage() {
|
||||
|
||||
{/* Top Connectors */}
|
||||
{stats && stats.topConnectors.length > 0 && (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-slate-900 mb-3">Most Connected Clients</h3>
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-5">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-3">Most Connected Clients</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{stats.topConnectors.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/clients/${c.id}`}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-slate-700 hover:bg-slate-100 dark:hover:bg-slate-600 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="w-7 h-7 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-xs font-semibold">
|
||||
<div className="w-7 h-7 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-full flex items-center justify-center text-xs font-semibold">
|
||||
{c.name.split(' ').map(n => n[0]).join('')}
|
||||
</div>
|
||||
<span className="text-sm font-medium text-slate-700">{c.name}</span>
|
||||
<span className="text-xs text-slate-400">{c.matchCount} matches</span>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{c.name}</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">{c.matchCount} matches</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@ export default function NetworkPage() {
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-500">
|
||||
<div className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Filter className="w-4 h-4" />
|
||||
Filter:
|
||||
</div>
|
||||
@@ -240,7 +240,7 @@ export default function NetworkPage() {
|
||||
onClick={() => setCategoryFilter(null)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
!categoryFilter ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
!categoryFilter ? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900' : 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
All ({matches.length})
|
||||
@@ -255,7 +255,7 @@ export default function NetworkPage() {
|
||||
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors',
|
||||
categoryFilter === cat ? cn(config.bg, config.color, 'ring-2 ring-offset-1', `ring-current`) : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
categoryFilter === cat ? cn(config.bg, config.darkBg, config.color, config.darkColor, 'ring-2 ring-offset-1 dark:ring-offset-slate-900', `ring-current`) : 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
{config.label} ({count})
|
||||
@@ -263,11 +263,11 @@ export default function NetworkPage() {
|
||||
);
|
||||
})}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<label className="text-xs text-slate-500">Min score:</label>
|
||||
<label className="text-xs text-slate-500 dark:text-slate-400">Min score:</label>
|
||||
<select
|
||||
value={minScore}
|
||||
onChange={(e) => setMinScore(parseInt(e.target.value))}
|
||||
className="text-sm border border-slate-200 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
className="text-sm border border-slate-200 dark:border-slate-600 rounded-lg px-2 py-1 bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
||||
>
|
||||
<option value={10}>10%</option>
|
||||
<option value={20}>20%</option>
|
||||
@@ -279,10 +279,10 @@ export default function NetworkPage() {
|
||||
|
||||
{/* Match Cards */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-12 text-center">
|
||||
<Network className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-slate-900 mb-1">No matches found</h3>
|
||||
<p className="text-sm text-slate-500 mb-4">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-12 text-center">
|
||||
<Network className="w-12 h-12 text-slate-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100 mb-1">No matches found</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">
|
||||
{matches.length === 0
|
||||
? 'Add more clients with detailed profiles (interests, industry, location) to find connections.'
|
||||
: 'Try adjusting the filter or minimum score.'}
|
||||
|
||||
@@ -39,14 +39,14 @@ function StatCard({ icon: Icon, label, value, sub, color }: {
|
||||
icon: typeof Users; label: string; value: number | string; sub?: string; color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5 flex items-start gap-4">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 flex items-start gap-4">
|
||||
<div className={cn('p-2.5 rounded-xl', color)}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-slate-900">{value}</p>
|
||||
<p className="text-sm text-slate-500">{label}</p>
|
||||
{sub && <p className="text-xs text-slate-400 mt-0.5">{sub}</p>}
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{label}</p>
|
||||
{sub && <p className="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -59,17 +59,17 @@ function BarChartSimple({ data, label, color }: {
|
||||
}) {
|
||||
const max = Math.max(...data.map(d => d.value), 1);
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-4">{label}</h3>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">{label}</h3>
|
||||
<div className="flex items-end gap-1.5 h-32">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<span className="text-[10px] text-slate-500 font-medium">{d.value || ''}</span>
|
||||
<span className="text-[10px] text-slate-500 dark:text-slate-400 font-medium">{d.value || ''}</span>
|
||||
<div
|
||||
className={cn('w-full rounded-t transition-all', color)}
|
||||
style={{ height: `${Math.max((d.value / max) * 100, 2)}%`, minHeight: d.value > 0 ? 4 : 2 }}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-400 truncate w-full text-center">
|
||||
<span className="text-[10px] text-slate-400 dark:text-slate-500 truncate w-full text-center">
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
@@ -83,15 +83,15 @@ function EngagementRing({ summary }: { summary: EngagementData['summary'] }) {
|
||||
const total = summary.engaged + summary.warm + summary.cooling + summary.cold;
|
||||
if (total === 0) return null;
|
||||
const segments = [
|
||||
{ label: 'Engaged', count: summary.engaged, color: 'bg-emerald-500', textColor: 'text-emerald-600', icon: Flame },
|
||||
{ label: 'Warm', count: summary.warm, color: 'bg-amber-400', textColor: 'text-amber-600', icon: ThermometerSun },
|
||||
{ label: 'Cooling', count: summary.cooling, color: 'bg-blue-400', textColor: 'text-blue-600', icon: Activity },
|
||||
{ label: 'Cold', count: summary.cold, color: 'bg-slate-300', textColor: 'text-slate-500', icon: Snowflake },
|
||||
{ label: 'Engaged', count: summary.engaged, color: 'bg-emerald-500', textColor: 'text-emerald-600 dark:text-emerald-400', icon: Flame },
|
||||
{ label: 'Warm', count: summary.warm, color: 'bg-amber-400', textColor: 'text-amber-600 dark:text-amber-400', icon: ThermometerSun },
|
||||
{ label: 'Cooling', count: summary.cooling, color: 'bg-blue-400', textColor: 'text-blue-600 dark:text-blue-400', icon: Activity },
|
||||
{ label: 'Cold', count: summary.cold, color: 'bg-slate-300 dark:bg-slate-500', textColor: 'text-slate-500 dark:text-slate-400', icon: Snowflake },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 mb-4">Engagement Breakdown</h3>
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300 mb-4">Engagement Breakdown</h3>
|
||||
{/* Stacked bar */}
|
||||
<div className="flex rounded-full h-4 overflow-hidden mb-4">
|
||||
{segments.map(s => (
|
||||
@@ -111,12 +111,12 @@ function EngagementRing({ summary }: { summary: EngagementData['summary'] }) {
|
||||
<div className={cn('w-3 h-3 rounded-full', s.color)} />
|
||||
<div>
|
||||
<span className={cn('text-sm font-semibold', s.textColor)}>{s.count}</span>
|
||||
<span className="text-xs text-slate-400 ml-1">{s.label}</span>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500 ml-1">{s.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-3">
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500 mt-3">
|
||||
Engaged = contacted in last 14 days • Warm = 15-30 days • Cooling = 31-60 days • Cold = 60+ days or never
|
||||
</p>
|
||||
</div>
|
||||
@@ -130,22 +130,22 @@ function TopList({ title, items, icon: Icon }: {
|
||||
}) {
|
||||
const max = Math.max(...items.map(i => i.value), 1);
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Icon className="w-4 h-4 text-slate-400" />
|
||||
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
|
||||
<Icon className="w-4 h-4 text-slate-400 dark:text-slate-500" />
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">{title}</h3>
|
||||
</div>
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-slate-400">No data yet</p>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">No data yet</p>
|
||||
)}
|
||||
<div className="space-y-2.5">
|
||||
{items.slice(0, 8).map((item, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-slate-700 truncate">{item.label}</span>
|
||||
<span className="text-slate-500 font-medium ml-2">{item.value}</span>
|
||||
<span className="text-slate-700 dark:text-slate-300 truncate">{item.label}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium ml-2">{item.value}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 bg-slate-100 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${(item.value / max) * 100}%` }}
|
||||
@@ -164,23 +164,23 @@ function AtRiskList({ title, clients: clientList }: {
|
||||
}) {
|
||||
if (clientList.length === 0) return null;
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-5">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
<h3 className="text-sm font-semibold text-slate-700">{title}</h3>
|
||||
<h3 className="text-sm font-semibold text-slate-700 dark:text-slate-300">{title}</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{clientList.map(c => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/clients/${c.id}`}
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
className="flex items-center justify-between p-2 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">{c.name}</p>
|
||||
{c.company && <p className="text-xs text-slate-400">{c.company}</p>}
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">{c.name}</p>
|
||||
{c.company && <p className="text-xs text-slate-400 dark:text-slate-500">{c.company}</p>}
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{c.lastContacted
|
||||
? `${Math.floor((Date.now() - new Date(c.lastContacted).getTime()) / (1000 * 60 * 60 * 24))}d ago`
|
||||
: 'Never'}
|
||||
@@ -247,13 +247,13 @@ export default function ReportsPage() {
|
||||
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Reports & Analytics</h1>
|
||||
<p className="text-sm text-slate-500">Overview of your CRM performance</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Reports & Analytics</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Overview of your CRM performance</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium text-slate-700 hover:bg-slate-50 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{exporting ? 'Exporting…' : 'Export Clients CSV'}
|
||||
@@ -265,16 +265,16 @@ export default function ReportsPage() {
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard icon={Users} label="Total Clients" value={overview.clients.total}
|
||||
sub={`+${overview.clients.newThisMonth} this month`}
|
||||
color="bg-blue-50 text-blue-600" />
|
||||
color="bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400" />
|
||||
<StatCard icon={Mail} label="Emails Sent" value={overview.emails.sent}
|
||||
sub={`${overview.emails.sentLast30Days} last 30 days`}
|
||||
color="bg-emerald-50 text-emerald-600" />
|
||||
color="bg-emerald-50 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400" />
|
||||
<StatCard icon={Calendar} label="Upcoming Events" value={overview.events.upcoming30Days}
|
||||
sub="Next 30 days"
|
||||
color="bg-amber-50 text-amber-600" />
|
||||
color="bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400" />
|
||||
<StatCard icon={TrendingUp} label="Contacted Recently" value={overview.clients.contactedRecently}
|
||||
sub={`${overview.clients.neverContacted} never contacted`}
|
||||
color="bg-purple-50 text-purple-600" />
|
||||
color="bg-purple-50 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ export default function ResetPasswordPage() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
@@ -66,16 +66,16 @@ export default function ResetPasswordPage() {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-red-500" />
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-red-100 dark:bg-red-900/50 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-red-500 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Invalid Reset Link</h1>
|
||||
<p className="text-slate-500 mb-6">{error}</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">Invalid Reset Link</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-6">{error}</p>
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
← Back to login
|
||||
</Link>
|
||||
@@ -86,13 +86,13 @@ export default function ResetPasswordPage() {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 p-4">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 p-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-green-100 rounded-2xl mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-green-100 dark:bg-green-900/50 rounded-2xl mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 mb-2">Password Reset</h1>
|
||||
<p className="text-slate-500 mb-6">Your password has been successfully reset. You can now sign in with your new password.</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">Password Reset</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-6">Your password has been successfully reset. You can now sign in with your new password.</p>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="px-6 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||
@@ -105,22 +105,22 @@ export default function ResetPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 flex items-center justify-center p-4">
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-slate-50 dark:from-slate-900 dark:via-slate-900 dark:to-slate-800 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 bg-blue-600 rounded-2xl mb-4">
|
||||
<Network className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Reset Password</h1>
|
||||
<p className="text-slate-500 mt-1">Choose a new password for your account</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Reset Password</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 mt-1">Choose a new password for your account</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 p-8">
|
||||
{email && (
|
||||
<div className="mb-6 p-3 bg-blue-50 rounded-lg">
|
||||
<p className="text-sm text-blue-700">
|
||||
<div className="mb-6 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-lg">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Resetting password for <strong>{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
@@ -128,13 +128,13 @@ export default function ResetPasswordPage() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{submitError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-lg">
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm rounded-lg">
|
||||
{submitError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">New Password</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">New Password</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
@@ -142,13 +142,13 @@ export default function ResetPasswordPage() {
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 pr-10 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="Min 8 characters"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 dark:text-slate-500 hover:text-slate-600 dark:hover:text-slate-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
@@ -156,13 +156,13 @@ export default function ResetPasswordPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Confirm Password</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
className="w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
</div>
|
||||
@@ -180,7 +180,7 @@ export default function ResetPasswordPage() {
|
||||
<div className="mt-4 text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-sm text-slate-500 hover:text-slate-700"
|
||||
className="text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
>
|
||||
← Back to login
|
||||
</Link>
|
||||
|
||||
@@ -7,7 +7,7 @@ import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||
|
||||
function StatusMessage({ type, message }: { type: 'success' | 'error'; message: string }) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 text-sm font-medium animate-fade-in ${type === 'success' ? 'text-emerald-600' : 'text-red-600'}`}>
|
||||
<div className={`flex items-center gap-2 text-sm font-medium animate-fade-in ${type === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}`}>
|
||||
{type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
|
||||
{message}
|
||||
</div>
|
||||
@@ -104,26 +104,26 @@ export default function SettingsPage() {
|
||||
|
||||
if (loading) return <PageLoader />;
|
||||
|
||||
const inputClass = 'w-full px-3.5 py-2.5 border border-slate-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
const labelClass = 'block text-sm font-medium text-slate-700 mb-1.5';
|
||||
const inputClass = 'w-full px-3.5 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg text-sm text-slate-900 dark:text-slate-100 bg-white dark:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
const labelClass = 'block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5';
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6 animate-fade-in">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">Settings</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">Manage your profile, email, and password</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Settings</h1>
|
||||
<p className="text-slate-500 dark:text-slate-400 text-sm mt-1">Manage your profile, email, and password</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information */}
|
||||
<form onSubmit={handleSaveProfile} className="space-y-6">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100">
|
||||
<div className="w-10 h-10 bg-blue-100 text-blue-700 rounded-lg flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100 dark:border-slate-700">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 rounded-lg flex items-center justify-center">
|
||||
<User className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Profile Information</h2>
|
||||
<p className="text-xs text-slate-500">Your public-facing details</p>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Profile Information</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Your public-facing details</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,14 +194,14 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Change Email */}
|
||||
<form onSubmit={handleChangeEmail}>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100">
|
||||
<div className="w-10 h-10 bg-amber-100 text-amber-700 rounded-lg flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100 dark:border-slate-700">
|
||||
<div className="w-10 h-10 bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300 rounded-lg flex items-center justify-center">
|
||||
<Mail className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Change Email</h2>
|
||||
<p className="text-xs text-slate-500">Update your login email address</p>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Change Email</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Update your login email address</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,14 +233,14 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Change Password */}
|
||||
<form onSubmit={handleChangePassword}>
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100">
|
||||
<div className="w-10 h-10 bg-emerald-100 text-emerald-700 rounded-lg flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-xl p-6 space-y-5">
|
||||
<div className="flex items-center gap-3 pb-4 border-b border-slate-100 dark:border-slate-700">
|
||||
<div className="w-10 h-10 bg-emerald-100 dark:bg-emerald-900/50 text-emerald-700 dark:text-emerald-300 rounded-lg flex items-center justify-center">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900">Change Password</h2>
|
||||
<p className="text-xs text-slate-500">Update your account password</p>
|
||||
<h2 className="font-semibold text-slate-900 dark:text-slate-100">Change Password</h2>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">Update your account password</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user