feat: initial SPA frontend for network app
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Editor
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
73
README.md
Normal file
73
README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>NetworkCRM</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3983
package-lock.json
generated
Normal file
3983
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "network-app-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zustand": "^5.0.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/App.tsx
Normal file
49
src/App.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect } 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 { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, isLoading } = useAuthStore();
|
||||||
|
if (isLoading) return <PageLoader />;
|
||||||
|
if (!isAuthenticated) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { checkSession, isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkSession();
|
||||||
|
}, [checkSession]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={
|
||||||
|
isAuthenticated ? <Navigate to="/" replace /> : <LoginPage />
|
||||||
|
} />
|
||||||
|
<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="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/Badge.tsx
Normal file
55
src/components/Badge.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: keyof typeof colorMap;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Badge({ children, color = 'gray', className, onClick, active }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'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',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventTypeBadge({ type }: { type: string }) {
|
||||||
|
const colors: Record<string, keyof typeof colorMap> = {
|
||||||
|
birthday: 'pink',
|
||||||
|
anniversary: 'purple',
|
||||||
|
followup: 'blue',
|
||||||
|
custom: 'gray',
|
||||||
|
};
|
||||||
|
return <Badge color={colors[type] || 'gray'}>{type}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailStatusBadge({ status }: { status: string }) {
|
||||||
|
const colors: Record<string, keyof typeof colorMap> = {
|
||||||
|
draft: 'yellow',
|
||||||
|
sent: 'green',
|
||||||
|
failed: 'red',
|
||||||
|
};
|
||||||
|
return <Badge color={colors[status] || 'gray'}>{status}</Badge>;
|
||||||
|
}
|
||||||
219
src/components/ClientForm.tsx
Normal file
219
src/components/ClientForm.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { ClientCreate, Client } from '@/types';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
import { X, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ClientFormProps {
|
||||||
|
initialData?: Client;
|
||||||
|
onSubmit: (data: ClientCreate) => Promise<void>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClientForm({ initialData, onSubmit, loading }: ClientFormProps) {
|
||||||
|
const [form, setForm] = useState<ClientCreate>({
|
||||||
|
firstName: initialData?.firstName || '',
|
||||||
|
lastName: initialData?.lastName || '',
|
||||||
|
email: initialData?.email || '',
|
||||||
|
phone: initialData?.phone || '',
|
||||||
|
street: initialData?.street || '',
|
||||||
|
city: initialData?.city || '',
|
||||||
|
state: initialData?.state || '',
|
||||||
|
zip: initialData?.zip || '',
|
||||||
|
company: initialData?.company || '',
|
||||||
|
role: initialData?.role || '',
|
||||||
|
industry: initialData?.industry || '',
|
||||||
|
birthday: initialData?.birthday?.split('T')[0] || '',
|
||||||
|
anniversary: initialData?.anniversary?.split('T')[0] || '',
|
||||||
|
interests: initialData?.interests || [],
|
||||||
|
family: initialData?.family || { spouse: '', children: [] },
|
||||||
|
notes: initialData?.notes || '',
|
||||||
|
tags: initialData?.tags || [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tagInput, setTagInput] = useState('');
|
||||||
|
const [interestInput, setInterestInput] = useState('');
|
||||||
|
|
||||||
|
const update = (field: string, value: any) => setForm({ ...form, [field]: value });
|
||||||
|
|
||||||
|
const addTag = () => {
|
||||||
|
if (tagInput.trim() && !form.tags?.includes(tagInput.trim())) {
|
||||||
|
update('tags', [...(form.tags || []), tagInput.trim()]);
|
||||||
|
setTagInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTag = (tag: string) => update('tags', form.tags?.filter((t) => t !== tag));
|
||||||
|
|
||||||
|
const addInterest = () => {
|
||||||
|
if (interestInput.trim() && !form.interests?.includes(interestInput.trim())) {
|
||||||
|
update('interests', [...(form.interests || []), interestInput.trim()]);
|
||||||
|
setInterestInput('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeInterest = (i: string) => update('interests', form.interests?.filter((x) => x !== i));
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (form.family?.spouse || (form.family?.children && form.family.children.length > 0)) {
|
||||||
|
cleaned.family = form.family;
|
||||||
|
}
|
||||||
|
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';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>First Name *</label>
|
||||||
|
<input required value={form.firstName} onChange={(e) => update('firstName', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Last Name *</label>
|
||||||
|
<input required value={form.lastName} onChange={(e) => update('lastName', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<input type="email" value={form.email} onChange={(e) => update('email', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Phone</label>
|
||||||
|
<input value={form.phone} onChange={(e) => update('phone', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Company</label>
|
||||||
|
<input value={form.company} onChange={(e) => update('company', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Role</label>
|
||||||
|
<input value={form.role} onChange={(e) => update('role', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Industry</label>
|
||||||
|
<input value={form.industry} onChange={(e) => update('industry', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Street</label>
|
||||||
|
<input value={form.street} onChange={(e) => update('street', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>City</label>
|
||||||
|
<input value={form.city} onChange={(e) => update('city', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>State</label>
|
||||||
|
<input value={form.state} onChange={(e) => update('state', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>ZIP</label>
|
||||||
|
<input value={form.zip} onChange={(e) => update('zip', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dates */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Birthday</label>
|
||||||
|
<input type="date" value={form.birthday} onChange={(e) => update('birthday', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Anniversary</label>
|
||||||
|
<input type="date" value={form.anniversary} onChange={(e) => update('anniversary', e.target.value)} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Tags</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addTag(); } }}
|
||||||
|
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>
|
||||||
|
</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">
|
||||||
|
{tag}
|
||||||
|
<button type="button" onClick={() => removeTag(tag)} className="hover:text-blue-900"><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interests */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Interests</label>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
value={interestInput}
|
||||||
|
onChange={(e) => setInterestInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); addInterest(); } }}
|
||||||
|
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>
|
||||||
|
</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">
|
||||||
|
{i}
|
||||||
|
<button type="button" onClick={() => removeInterest(i)} className="hover:text-emerald-900"><X className="w-3 h-3" /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={form.notes}
|
||||||
|
onChange={(e) => update('notes', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Any additional notes..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <LoadingSpinner size="sm" className="text-white" />}
|
||||||
|
{initialData ? 'Update Client' : 'Add Client'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
169
src/components/EmailComposeModal.tsx
Normal file
169
src/components/EmailComposeModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useEmailsStore } from '@/stores/emails';
|
||||||
|
import type { Email } from '@/types';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
import { Sparkles, Send, Gift } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
clientId: string;
|
||||||
|
clientName: string;
|
||||||
|
onGenerated?: (email: Email) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmailComposeModal({ isOpen, onClose, clientId, clientName, onGenerated }: Props) {
|
||||||
|
const { isGenerating, generateEmail, generateBirthdayEmail, sendEmail, updateEmail } = useEmailsStore();
|
||||||
|
const [purpose, setPurpose] = useState('');
|
||||||
|
const [provider, setProvider] = useState<'anthropic' | 'openai'>('anthropic');
|
||||||
|
const [generated, setGenerated] = useState<Email | null>(null);
|
||||||
|
const [editSubject, setEditSubject] = useState('');
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
try {
|
||||||
|
const email = await generateEmail(clientId, purpose, provider);
|
||||||
|
setGenerated(email);
|
||||||
|
setEditSubject(email.subject);
|
||||||
|
setEditContent(email.content);
|
||||||
|
onGenerated?.(email);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateBirthday = async () => {
|
||||||
|
try {
|
||||||
|
const email = await generateBirthdayEmail(clientId, provider);
|
||||||
|
setGenerated(email);
|
||||||
|
setEditSubject(email.subject);
|
||||||
|
setEditContent(email.content);
|
||||||
|
onGenerated?.(email);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!generated) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await updateEmail(generated.id, { subject: editSubject, content: editContent });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!generated) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
if (editSubject !== generated.subject || editContent !== generated.content) {
|
||||||
|
await updateEmail(generated.id, { subject: editSubject, content: editContent });
|
||||||
|
}
|
||||||
|
await sendEmail(generated.id);
|
||||||
|
onClose();
|
||||||
|
setGenerated(null);
|
||||||
|
setPurpose('');
|
||||||
|
} catch {
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setGenerated(null);
|
||||||
|
setPurpose('');
|
||||||
|
setEditSubject('');
|
||||||
|
setEditContent('');
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
>
|
||||||
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
<option value="openai">OpenAI (GPT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!purpose || isGenerating}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isGenerating ? <LoadingSpinner size="sm" className="text-white" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
Generate Email
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Gift className="w-4 h-4" />
|
||||||
|
Birthday
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{saving ? 'Saving...' : 'Save Draft'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={sending}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{sending ? <LoadingSpinner size="sm" className="text-white" /> : <Send className="w-4 h-4" />}
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
src/components/EmptyState.tsx
Normal file
31
src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
{action && (
|
||||||
|
<button
|
||||||
|
onClick={action.onClick}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/components/Layout.tsx
Normal file
132
src/components/Layout.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import {
|
||||||
|
LayoutDashboard, Users, Calendar, Mail, Settings,
|
||||||
|
LogOut, Menu, X, ChevronLeft, Network,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ path: '/clients', label: 'Clients', icon: Users },
|
||||||
|
{ path: '/events', label: 'Events', icon: Calendar },
|
||||||
|
{ path: '/emails', label: 'Emails', icon: Mail },
|
||||||
|
{ path: '/settings', label: 'Settings', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const location = useLocation();
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-50">
|
||||||
|
{/* Mobile overlay */}
|
||||||
|
{mobileOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30 z-40 lg:hidden"
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside
|
||||||
|
className={cn(
|
||||||
|
'fixed lg:static inset-y-0 left-0 z-50 flex flex-col bg-white border-r border-slate-200 transition-all duration-300',
|
||||||
|
collapsed ? 'w-16' : 'w-64',
|
||||||
|
mobileOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Logo */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex items-center h-16 px-4 border-b border-slate-200',
|
||||||
|
collapsed ? 'justify-center' : 'gap-3'
|
||||||
|
)}>
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||||
|
<Network className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
{!collapsed && <span className="font-bold text-lg text-slate-900">NetworkCRM</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 py-4 px-2 space-y-1">
|
||||||
|
{navItems.map(({ path, label, icon: Icon }) => {
|
||||||
|
const isActive = path === '/' ? location.pathname === '/' : location.pathname.startsWith(path);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={path}
|
||||||
|
to={path}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 flex-shrink-0" />
|
||||||
|
{!collapsed && <span>{label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Collapse button (desktop) */}
|
||||||
|
<button
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
className="hidden lg:flex items-center justify-center h-10 mx-2 mb-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className={cn('w-5 h-5 transition-transform', collapsed && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User / Logout */}
|
||||||
|
<div className={cn(
|
||||||
|
'border-t border-slate-200 p-3',
|
||||||
|
collapsed ? 'flex flex-col items-center gap-2' : 'flex items-center gap-3'
|
||||||
|
)}>
|
||||||
|
<div className="w-8 h-8 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">
|
||||||
|
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</div>
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">{user?.name}</p>
|
||||||
|
<p className="text-xs text-slate-500 truncate">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors"
|
||||||
|
title="Logout"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-4 lg:px-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="lg:hidden p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-100"
|
||||||
|
>
|
||||||
|
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-auto p-4 lg:p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/components/LoadingSpinner.tsx
Normal file
15
src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export default function LoadingSpinner({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
|
||||||
|
const sizeClass = size === 'sm' ? 'w-4 h-4' : size === 'lg' ? 'w-8 h-8' : 'w-6 h-6';
|
||||||
|
return <Loader2 className={cn('animate-spin text-blue-600', sizeClass, className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/Modal.tsx
Normal file
50
src/components/Modal.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, title, children, size = 'md' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className={cn(
|
||||||
|
'relative bg-white rounded-xl shadow-xl w-full max-h-[85vh] flex flex-col animate-fade-in',
|
||||||
|
size === 'sm' && 'max-w-md',
|
||||||
|
size === 'md' && 'max-w-lg',
|
||||||
|
size === 'lg' && 'max-w-2xl',
|
||||||
|
size === 'xl' && 'max-w-4xl',
|
||||||
|
)}>
|
||||||
|
<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">{title}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto px-6 py-4">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/index.css
Normal file
53
src/index.css
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-dark: #1d4ed8;
|
||||||
|
--color-primary-light: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
198
src/lib/api.ts
Normal file
198
src/lib/api.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import type { Profile, Client, ClientCreate, Event, EventCreate, Email, EmailGenerate, User } from '@/types';
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.PROD
|
||||||
|
? 'https://api.donovankelly.xyz/api'
|
||||||
|
: '/api';
|
||||||
|
|
||||||
|
const AUTH_BASE = import.meta.env.PROD
|
||||||
|
? 'https://api.donovankelly.xyz'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
class ApiClient {
|
||||||
|
private async fetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(error.error || error.message || 'Request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
if (!text) return {} as T;
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
async login(email: string, password: string) {
|
||||||
|
const response = await fetch(`${AUTH_BASE}/api/auth/sign-in/email`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json().catch(() => ({ message: 'Login failed' }));
|
||||||
|
throw new Error(error.message || 'Login failed');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
await fetch(`${AUTH_BASE}/api/auth/sign-out`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(): Promise<{ user: User } | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${AUTH_BASE}/api/auth/get-session`, {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return response.json();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile
|
||||||
|
async getProfile(): Promise<Profile> {
|
||||||
|
return this.fetch('/profile');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateProfile(data: Partial<Profile>): Promise<Profile> {
|
||||||
|
return this.fetch('/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients
|
||||||
|
async getClients(params?: { search?: string; tag?: string }): Promise<Client[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.search) searchParams.set('search', params.search);
|
||||||
|
if (params?.tag) searchParams.set('tag', params.tag);
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.fetch(`/clients${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClient(id: string): Promise<Client> {
|
||||||
|
return this.fetch(`/clients/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createClient(data: ClientCreate): Promise<Client> {
|
||||||
|
return this.fetch('/clients', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateClient(id: string, data: Partial<ClientCreate>): Promise<Client> {
|
||||||
|
return this.fetch(`/clients/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteClient(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/clients/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async markContacted(id: string): Promise<Client> {
|
||||||
|
return this.fetch(`/clients/${id}/contacted`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Events
|
||||||
|
async getEvents(params?: { clientId?: string; type?: string; upcoming?: number }): Promise<Event[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.clientId) searchParams.set('clientId', params.clientId);
|
||||||
|
if (params?.type) searchParams.set('type', params.type);
|
||||||
|
if (params?.upcoming) searchParams.set('upcoming', String(params.upcoming));
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.fetch(`/events${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEvent(id: string): Promise<Event> {
|
||||||
|
return this.fetch(`/events/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEvent(data: EventCreate): Promise<Event> {
|
||||||
|
return this.fetch('/events', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEvent(id: string, data: Partial<EventCreate>): Promise<Event> {
|
||||||
|
return this.fetch(`/events/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEvent(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/events/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncAllEvents(): Promise<void> {
|
||||||
|
await this.fetch('/events/sync-all', { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async syncClientEvents(clientId: string): Promise<void> {
|
||||||
|
await this.fetch(`/events/sync/${clientId}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emails
|
||||||
|
async getEmails(params?: { status?: string; clientId?: string }): Promise<Email[]> {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
if (params?.status) searchParams.set('status', params.status);
|
||||||
|
if (params?.clientId) searchParams.set('clientId', params.clientId);
|
||||||
|
const query = searchParams.toString();
|
||||||
|
return this.fetch(`/emails${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEmail(id: string): Promise<Email> {
|
||||||
|
return this.fetch(`/emails/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateEmail(data: EmailGenerate): Promise<Email> {
|
||||||
|
return this.fetch('/emails/generate', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateBirthdayEmail(clientId: string, provider?: string): Promise<Email> {
|
||||||
|
return this.fetch('/emails/generate-birthday', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ clientId, provider }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEmail(id: string, data: { subject?: string; content?: string }): Promise<Email> {
|
||||||
|
return this.fetch(`/emails/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(id: string): Promise<Email> {
|
||||||
|
return this.fetch(`/emails/${id}/send`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(id: string): Promise<void> {
|
||||||
|
await this.fetch(`/emails/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = new ApiClient();
|
||||||
68
src/lib/utils.ts
Normal file
68
src/lib/utils.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const dateObj = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||||
|
|
||||||
|
if (dateObj.getTime() === today.getTime()) return 'Today';
|
||||||
|
if (dateObj.getTime() === tomorrow.getTime()) return 'Tomorrow';
|
||||||
|
|
||||||
|
const yesterday = new Date(today);
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
if (dateObj.getTime() === yesterday.getTime()) return 'Yesterday';
|
||||||
|
|
||||||
|
if (dateObj.getFullYear() === now.getFullYear()) {
|
||||||
|
return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
return dateObj.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFullDate(date: string | Date | undefined): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitials(firstName: string, lastName: string): string {
|
||||||
|
return `${firstName?.[0] || ''}${lastName?.[0] || ''}`.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelativeTime(date: string | Date | undefined): string {
|
||||||
|
if (!date) return 'Never';
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - d.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Today';
|
||||||
|
if (diffDays === 1) return 'Yesterday';
|
||||||
|
if (diffDays < 7) return `${diffDays} days ago`;
|
||||||
|
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
|
||||||
|
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
||||||
|
return `${Math.floor(diffDays / 365)} years ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaysUntil(date: string | Date): number {
|
||||||
|
const d = new Date(date);
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
|
const target = new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
||||||
|
// For recurring events, project to this year
|
||||||
|
if (target < today) {
|
||||||
|
target.setFullYear(today.getFullYear());
|
||||||
|
if (target < today) target.setFullYear(today.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return Math.ceil((target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
282
src/pages/ClientDetailPage.tsx
Normal file
282
src/pages/ClientDetailPage.tsx
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useClientsStore } from '@/stores/clients';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Event, Email } from '@/types';
|
||||||
|
import {
|
||||||
|
ArrowLeft, Edit3, Trash2, Phone, Mail, MapPin, Building2,
|
||||||
|
Briefcase, Gift, Heart, Star, Users, Calendar, Send,
|
||||||
|
CheckCircle2, Sparkles, Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn, formatDate, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
|
import Badge, { EventTypeBadge, EmailStatusBadge } from '@/components/Badge';
|
||||||
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import ClientForm from '@/components/ClientForm';
|
||||||
|
import EmailComposeModal from '@/components/EmailComposeModal';
|
||||||
|
|
||||||
|
export default function ClientDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { selectedClient, isLoading, fetchClient, updateClient, deleteClient, markContacted } = useClientsStore();
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<'info' | 'events' | 'emails'>('info');
|
||||||
|
const [showEdit, setShowEdit] = useState(false);
|
||||||
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchClient(id);
|
||||||
|
api.getEvents({ clientId: id }).then(setEvents).catch(() => {});
|
||||||
|
api.getEmails({ clientId: id }).then(setEmails).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [id, fetchClient]);
|
||||||
|
|
||||||
|
if (isLoading || !selectedClient) return <PageLoader />;
|
||||||
|
|
||||||
|
const client = selectedClient;
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm('Delete this client? This cannot be undone.')) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteClient(client.id);
|
||||||
|
navigate('/clients');
|
||||||
|
} catch {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkContacted = async () => {
|
||||||
|
await markContacted(client.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (data: any) => {
|
||||||
|
await updateClient(client.id, data);
|
||||||
|
setShowEdit(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabs: { key: 'info' | 'events' | 'emails'; label: string; count?: number; icon: typeof Users }[] = [
|
||||||
|
{ key: 'info', label: 'Info', icon: Users },
|
||||||
|
{ key: 'events', label: 'Events', count: events.length, icon: Calendar },
|
||||||
|
{ key: 'emails', label: 'Emails', count: emails.length, icon: Mail },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6 animate-fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<button onClick={() => navigate('/clients')} className="mt-1 p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-16 h-16 bg-blue-100 text-blue-700 rounded-2xl flex items-center justify-center text-xl font-bold">
|
||||||
|
{getInitials(client.firstName, client.lastName)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
{client.firstName} {client.lastName}
|
||||||
|
</h1>
|
||||||
|
{client.company && (
|
||||||
|
<p className="text-slate-500">
|
||||||
|
{client.role ? `${client.role} at ` : ''}{client.company}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{client.tags && client.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{client.tags.map((tag) => <Badge key={tag} color="blue">{tag}</Badge>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={handleMarkContacted} className="flex items-center gap-2 px-3 py-2 bg-emerald-50 text-emerald-700 rounded-lg text-sm font-medium hover:bg-emerald-100 transition-colors">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Contacted
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowCompose(true)} className="flex items-center gap-2 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium hover:bg-blue-100 transition-colors">
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Generate Email
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setShowEdit(true)} className="p-2 rounded-lg text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition-colors">
|
||||||
|
<Edit3 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={handleDelete} disabled={deleting} className="p-2 rounded-lg text-slate-400 hover:bg-red-50 hover:text-red-600 transition-colors">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-slate-200">
|
||||||
|
<div className="flex gap-6">
|
||||||
|
{tabs.map(({ key, label, count, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => setActiveTab(key)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 pb-3 border-b-2 text-sm font-medium transition-colors',
|
||||||
|
activeTab === key
|
||||||
|
? 'border-blue-600 text-blue-600'
|
||||||
|
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
{label}
|
||||||
|
{count !== undefined && count > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-slate-100 text-slate-600 rounded-full text-xs">{count}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'info' && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-900">Contact Information</h3>
|
||||||
|
{client.email && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<a href={`mailto:${client.email}`} className="text-blue-600 hover:underline">{client.email}</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.phone && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-700">{client.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(client.street || client.city) && (
|
||||||
|
<div className="flex items-start gap-3 text-sm">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400 mt-0.5" />
|
||||||
|
<span className="text-slate-700">
|
||||||
|
{[client.street, client.city, client.state, client.zip].filter(Boolean).join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.company && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Building2 className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-700">{client.company}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.industry && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Briefcase className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-700">{client.industry}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Clock className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-500">Last contacted: {getRelativeTime(client.lastContacted)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Personal */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5 space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-900">Personal Details</h3>
|
||||||
|
{client.birthday && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Gift className="w-4 h-4 text-pink-500" />
|
||||||
|
<span className="text-slate-700">Birthday: {formatDate(client.birthday)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.anniversary && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Heart className="w-4 h-4 text-purple-500" />
|
||||||
|
<span className="text-slate-700">Anniversary: {formatDate(client.anniversary)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.interests && client.interests.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500 mb-2">
|
||||||
|
<Star className="w-4 h-4" />
|
||||||
|
Interests
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{client.interests.map((i) => <Badge key={i} color="green">{i}</Badge>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.family?.spouse && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Users className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-slate-700">Spouse: {client.family.spouse}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{client.family?.children && client.family.children.length > 0 && (
|
||||||
|
<div className="text-sm text-slate-700 ml-7">
|
||||||
|
Children: {client.family.children.join(', ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
{client.notes && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-5 md:col-span-2">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-2">Notes</h3>
|
||||||
|
<p className="text-sm text-slate-700 whitespace-pre-wrap">{client.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'events' && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No events for this client</p>
|
||||||
|
) : (
|
||||||
|
events.map((event) => (
|
||||||
|
<div key={event.id} className="flex items-center gap-3 px-5 py-4">
|
||||||
|
<EventTypeBadge type={event.type} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900">{event.title}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatDate(event.date)} {event.recurring && '· Recurring'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'emails' && (
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl divide-y divide-slate-100">
|
||||||
|
{emails.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No emails for this client</p>
|
||||||
|
) : (
|
||||||
|
emails.map((email) => (
|
||||||
|
<Link key={email.id} to={`/emails?id=${email.id}`} className="flex items-center gap-3 px-5 py-4 hover:bg-slate-50 transition-colors">
|
||||||
|
<EmailStatusBadge status={email.status} />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">{email.subject}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatDate(email.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal isOpen={showEdit} onClose={() => setShowEdit(false)} title="Edit Client" size="lg">
|
||||||
|
<ClientForm initialData={client} onSubmit={handleUpdate} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Email Compose Modal */}
|
||||||
|
<EmailComposeModal
|
||||||
|
isOpen={showCompose}
|
||||||
|
onClose={() => setShowCompose(false)}
|
||||||
|
clientId={client.id}
|
||||||
|
clientName={`${client.firstName} ${client.lastName}`}
|
||||||
|
onGenerated={(email) => setEmails((prev) => [email, ...prev])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/pages/ClientsPage.tsx
Normal file
180
src/pages/ClientsPage.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useClientsStore } from '@/stores/clients';
|
||||||
|
import { Search, Plus, Users, X } from 'lucide-react';
|
||||||
|
import { cn, getRelativeTime, getInitials } from '@/lib/utils';
|
||||||
|
import Badge from '@/components/Badge';
|
||||||
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
import ClientForm from '@/components/ClientForm';
|
||||||
|
|
||||||
|
export default function ClientsPage() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { clients, isLoading, searchQuery, selectedTag, setSearchQuery, setSelectedTag, fetchClients, createClient } = useClientsStore();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchClients();
|
||||||
|
}, [fetchClients]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (location.state?.openCreate) {
|
||||||
|
setShowCreate(true);
|
||||||
|
window.history.replaceState({}, '');
|
||||||
|
}
|
||||||
|
}, [location.state]);
|
||||||
|
|
||||||
|
// Client-side filtering for immediate feedback
|
||||||
|
const filteredClients = useMemo(() => {
|
||||||
|
let result = clients;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(
|
||||||
|
(c) =>
|
||||||
|
`${c.firstName} ${c.lastName}`.toLowerCase().includes(q) ||
|
||||||
|
c.email?.toLowerCase().includes(q) ||
|
||||||
|
c.company?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (selectedTag) {
|
||||||
|
result = result.filter((c) => c.tags?.includes(selectedTag));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [clients, searchQuery, selectedTag]);
|
||||||
|
|
||||||
|
// All unique tags
|
||||||
|
const allTags = useMemo(() => {
|
||||||
|
const tags = new Set<string>();
|
||||||
|
clients.forEach((c) => c.tags?.forEach((t) => tags.add(t)));
|
||||||
|
return Array.from(tags).sort();
|
||||||
|
}, [clients]);
|
||||||
|
|
||||||
|
const handleCreate = async (data: any) => {
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await createClient(data);
|
||||||
|
setShowCreate(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && clients.length === 0) return <PageLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-5 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Clients</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">{clients.length} contacts in your network</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Client
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search + Tags */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search clients..."
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allTags.map((tag) => (
|
||||||
|
<Badge
|
||||||
|
key={tag}
|
||||||
|
color="blue"
|
||||||
|
active={selectedTag === tag}
|
||||||
|
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{selectedTag && (
|
||||||
|
<button onClick={() => setSelectedTag(null)} className="text-xs text-slate-500 hover:text-slate-700 ml-1">
|
||||||
|
Clear filter
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client Grid */}
|
||||||
|
{filteredClients.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title={searchQuery || selectedTag ? 'No matches found' : 'No clients yet'}
|
||||||
|
description={searchQuery || selectedTag ? 'Try adjusting your search or filters' : 'Add your first client to get started'}
|
||||||
|
action={!searchQuery && !selectedTag ? { label: 'Add Client', onClick: () => setShowCreate(true) } : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{filteredClients.map((client) => (
|
||||||
|
<Link
|
||||||
|
key={client.id}
|
||||||
|
to={`/clients/${client.id}`}
|
||||||
|
className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md hover:border-slate-300 transition-all group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-11 h-11 bg-blue-100 text-blue-700 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 group-hover:bg-blue-200 transition-colors">
|
||||||
|
{getInitials(client.firstName, client.lastName)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-slate-900 truncate">
|
||||||
|
{client.firstName} {client.lastName}
|
||||||
|
</h3>
|
||||||
|
{client.company && (
|
||||||
|
<p className="text-sm text-slate-500 truncate">{client.role ? `${client.role} at ` : ''}{client.company}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{client.tags && client.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||||
|
{client.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span key={tag} className="px-2 py-0.5 bg-slate-100 text-slate-600 rounded-full text-xs">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{client.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-slate-400">+{client.tags.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 text-xs text-slate-400">
|
||||||
|
Last contacted: {getRelativeTime(client.lastContacted)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<Modal isOpen={showCreate} onClose={() => setShowCreate(false)} title="Add Client" size="lg">
|
||||||
|
<ClientForm onSubmit={handleCreate} loading={creating} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
src/pages/DashboardPage.tsx
Normal file
151
src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Client, Event, Email } from '@/types';
|
||||||
|
import { Users, Calendar, Mail, Plus, ArrowRight, Gift, Heart, Clock } from 'lucide-react';
|
||||||
|
import { formatDate, getDaysUntil } from '@/lib/utils';
|
||||||
|
import { EventTypeBadge } from '@/components/Badge';
|
||||||
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [clients, setClients] = useState<Client[]>([]);
|
||||||
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
|
const [emails, setEmails] = useState<Email[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
api.getClients().catch(() => []),
|
||||||
|
api.getEvents({ upcoming: 7 }).catch(() => []),
|
||||||
|
api.getEmails({ status: 'draft' }).catch(() => []),
|
||||||
|
]).then(([c, e, em]) => {
|
||||||
|
setClients(c);
|
||||||
|
setEvents(e);
|
||||||
|
setEmails(em);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <PageLoader />;
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Total Clients', value: clients.length, icon: Users, color: 'bg-blue-50 text-blue-600', link: '/clients' },
|
||||||
|
{ label: 'Upcoming Events', value: events.length, icon: Calendar, color: 'bg-emerald-50 text-emerald-600', link: '/events' },
|
||||||
|
{ label: 'Pending Drafts', value: emails.length, icon: Mail, color: 'bg-amber-50 text-amber-600', link: '/emails' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const eventIcon = (type: string) => {
|
||||||
|
if (type === 'birthday') return <Gift className="w-4 h-4 text-pink-500" />;
|
||||||
|
if (type === 'anniversary') return <Heart className="w-4 h-4 text-purple-500" />;
|
||||||
|
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto space-y-6 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Dashboard</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-1">Welcome back. Here's your overview.</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/clients"
|
||||||
|
state={{ openCreate: true }}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Client
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
{stats.map((stat) => (
|
||||||
|
<Link
|
||||||
|
key={stat.label}
|
||||||
|
to={stat.link}
|
||||||
|
className="bg-white border border-slate-200 rounded-xl p-5 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${stat.color}`}>
|
||||||
|
<stat.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-slate-900">{stat.value}</p>
|
||||||
|
<p className="text-sm text-slate-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Upcoming Events */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">Upcoming Events</h2>
|
||||||
|
<Link to="/events" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||||
|
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No upcoming events</p>
|
||||||
|
) : (
|
||||||
|
events.slice(0, 5).map((event) => (
|
||||||
|
<div key={event.id} className="flex items-center gap-3 px-5 py-3">
|
||||||
|
{eventIcon(event.type)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">{event.title}</p>
|
||||||
|
<p className="text-xs text-slate-500">{formatDate(event.date)}</p>
|
||||||
|
</div>
|
||||||
|
<EventTypeBadge type={event.type} />
|
||||||
|
<span className="text-xs text-slate-400 whitespace-nowrap">
|
||||||
|
{getDaysUntil(event.date)}d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Clients */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||||
|
<h2 className="font-semibold text-slate-900">Recent Clients</h2>
|
||||||
|
<Link to="/clients" className="text-sm text-blue-600 hover:text-blue-700 flex items-center gap-1">
|
||||||
|
View all <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{clients.length === 0 ? (
|
||||||
|
<p className="px-5 py-8 text-center text-sm text-slate-400">No clients yet</p>
|
||||||
|
) : (
|
||||||
|
[...clients]
|
||||||
|
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((client) => (
|
||||||
|
<Link
|
||||||
|
key={client.id}
|
||||||
|
to={`/clients/${client.id}`}
|
||||||
|
className="flex items-center gap-3 px-5 py-3 hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{client.firstName[0]}{client.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-900 truncate">
|
||||||
|
{client.firstName} {client.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 truncate">
|
||||||
|
{client.company || client.email || 'No details'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/pages/EmailsPage.tsx
Normal file
236
src/pages/EmailsPage.tsx
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useEmailsStore } from '@/stores/emails';
|
||||||
|
import { useClientsStore } from '@/stores/clients';
|
||||||
|
import { Mail, Send, Trash2, Edit3, Sparkles, Gift } from 'lucide-react';
|
||||||
|
import { cn, formatDate } from '@/lib/utils';
|
||||||
|
import { EmailStatusBadge } from '@/components/Badge';
|
||||||
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
import { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
import Modal from '@/components/Modal';
|
||||||
|
|
||||||
|
export default function EmailsPage() {
|
||||||
|
const { emails, isLoading, isGenerating, statusFilter, setStatusFilter, fetchEmails, generateEmail, generateBirthdayEmail, updateEmail, sendEmail, deleteEmail } = useEmailsStore();
|
||||||
|
const { clients, fetchClients } = useClientsStore();
|
||||||
|
const [showCompose, setShowCompose] = useState(false);
|
||||||
|
const [editingEmail, setEditingEmail] = useState<string | null>(null);
|
||||||
|
const [editSubject, setEditSubject] = useState('');
|
||||||
|
const [editContent, setEditContent] = useState('');
|
||||||
|
const [composeForm, setComposeForm] = useState({ clientId: '', purpose: '', provider: 'anthropic' as 'anthropic' | 'openai' });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmails();
|
||||||
|
fetchClients();
|
||||||
|
}, [fetchEmails, fetchClients]);
|
||||||
|
|
||||||
|
const filtered = statusFilter ? emails.filter((e) => e.status === statusFilter) : emails;
|
||||||
|
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
try {
|
||||||
|
await generateEmail(composeForm.clientId, composeForm.purpose, composeForm.provider);
|
||||||
|
setShowCompose(false);
|
||||||
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGenerateBirthday = async () => {
|
||||||
|
try {
|
||||||
|
await generateBirthdayEmail(composeForm.clientId, composeForm.provider);
|
||||||
|
setShowCompose(false);
|
||||||
|
setComposeForm({ clientId: '', purpose: '', provider: 'anthropic' });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (email: typeof emails[0]) => {
|
||||||
|
setEditingEmail(email.id);
|
||||||
|
setEditSubject(email.subject);
|
||||||
|
setEditContent(email.content);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editingEmail) return;
|
||||||
|
await updateEmail(editingEmail, { subject: editSubject, content: editContent });
|
||||||
|
setEditingEmail(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && emails.length === 0) return <PageLoader />;
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ key: null, label: 'All' },
|
||||||
|
{ key: 'draft', label: 'Drafts' },
|
||||||
|
{ key: 'sent', label: 'Sent' },
|
||||||
|
{ key: 'failed', label: 'Failed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompose(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Compose
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{statusFilters.map(({ key, label }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => setStatusFilter(key)}
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emails list */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Mail}
|
||||||
|
title="No emails"
|
||||||
|
description="Generate AI-powered emails for your clients"
|
||||||
|
action={{ label: 'Compose Email', onClick: () => setShowCompose(true) }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{filtered.map((email) => (
|
||||||
|
<div key={email.id} className="bg-white border border-slate-200 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"
|
||||||
|
/>
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
<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={saveEdit} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<EmailStatusBadge status={email.status} />
|
||||||
|
{email.client && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
To: {email.client.firstName} {email.client.lastName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">{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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4 pt-3 border-t border-slate-100">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" /> Send
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Compose Modal */}
|
||||||
|
<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>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<option value="">Select a client...</option>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
>
|
||||||
|
<option value="anthropic">Anthropic (Claude)</option>
|
||||||
|
<option value="openai">OpenAI (GPT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!composeForm.clientId || !composeForm.purpose || isGenerating}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isGenerating ? <LoadingSpinner size="sm" className="text-white" /> : <Sparkles className="w-4 h-4" />}
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<Gift className="w-4 h-4" />
|
||||||
|
Birthday
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
266
src/pages/EventsPage.tsx
Normal file
266
src/pages/EventsPage.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useEventsStore } from '@/stores/events';
|
||||||
|
import { useClientsStore } from '@/stores/clients';
|
||||||
|
import { Calendar, RefreshCw, Plus, Gift, Heart, Clock, Star } 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 { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
const eventTypes = [
|
||||||
|
{ key: null, label: 'All', icon: Calendar },
|
||||||
|
{ key: 'birthday', label: 'Birthdays', icon: Gift },
|
||||||
|
{ key: 'anniversary', label: 'Anniversaries', icon: Heart },
|
||||||
|
{ key: 'followup', label: 'Follow-ups', icon: Clock },
|
||||||
|
{ key: 'custom', label: 'Custom', icon: Star },
|
||||||
|
];
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEvents();
|
||||||
|
fetchClients();
|
||||||
|
}, [fetchEvents, fetchClients]);
|
||||||
|
|
||||||
|
const filtered = typeFilter
|
||||||
|
? events.filter((e) => e.type === typeFilter)
|
||||||
|
: events;
|
||||||
|
|
||||||
|
const sorted = [...filtered].sort(
|
||||||
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSync = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
await syncAll();
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading && events.length === 0) return <PageLoader />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto space-y-5 animate-fade-in">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', syncing && 'animate-spin')} />
|
||||||
|
Sync All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
New Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type filters */}
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{eventTypes.map(({ key, label, icon: Icon }) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
onClick={() => setTypeFilter(key)}
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Modal */}
|
||||||
|
<CreateEventModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
clients={clients}
|
||||||
|
onCreate={async (data) => {
|
||||||
|
await createEvent(data);
|
||||||
|
setShowCreate(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateEventModal({ isOpen, onClose, clients, onCreate }: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
clients: { id: string; firstName: string; lastName: string }[];
|
||||||
|
onCreate: (data: EventCreate) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState<EventCreate>({
|
||||||
|
type: 'custom',
|
||||||
|
title: '',
|
||||||
|
date: '',
|
||||||
|
clientId: '',
|
||||||
|
recurring: false,
|
||||||
|
reminderDays: 7,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = { ...form };
|
||||||
|
if (!data.clientId) delete data.clientId;
|
||||||
|
await onCreate(data);
|
||||||
|
setForm({ type: 'custom', title: '', date: '', clientId: '', recurring: false, reminderDays: 7 });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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>
|
||||||
|
<input required value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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>
|
||||||
|
<option value="anniversary">Anniversary</option>
|
||||||
|
<option value="followup">Follow-up</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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>
|
||||||
|
<select value={form.clientId} onChange={(e) => setForm({ ...form, clientId: e.target.value })} className={inputClass}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{clients.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.firstName} {c.lastName}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={form.recurring}
|
||||||
|
onChange={(e) => setForm({ ...form, recurring: e.target.checked })}
|
||||||
|
className="rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
Recurring annually
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<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"
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
<span>days before</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{loading && <LoadingSpinner size="sm" className="text-white" />}
|
||||||
|
Create Event
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
src/pages/LoginPage.tsx
Normal file
97
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { Network, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import LoadingSpinner from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { login } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Login failed');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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="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">NetworkCRM</h1>
|
||||||
|
<p className="text-slate-500 mt-1">Sign in to manage your network</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||||
|
<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">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 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"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
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"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{loading ? <LoadingSpinner size="sm" className="text-white" /> : null}
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/pages/SettingsPage.tsx
Normal file
148
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import type { Profile } from '@/types';
|
||||||
|
import { Save, User } from 'lucide-react';
|
||||||
|
import LoadingSpinner, { PageLoader } from '@/components/LoadingSpinner';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getProfile().then((p) => {
|
||||||
|
setProfile(p);
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!profile) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSuccess(false);
|
||||||
|
try {
|
||||||
|
const updated = await api.updateProfile(profile);
|
||||||
|
setProfile(updated);
|
||||||
|
setSuccess(true);
|
||||||
|
setTimeout(() => setSuccess(false), 3000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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 and preferences</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-6">
|
||||||
|
{/* Profile section */}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Name</label>
|
||||||
|
<input
|
||||||
|
value={profile?.name || ''}
|
||||||
|
onChange={(e) => setProfile({ ...profile!, name: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Email</label>
|
||||||
|
<input
|
||||||
|
value={profile?.email || ''}
|
||||||
|
disabled
|
||||||
|
className={`${inputClass} bg-slate-50 text-slate-500`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Title</label>
|
||||||
|
<input
|
||||||
|
value={profile?.title || ''}
|
||||||
|
onChange={(e) => setProfile({ ...profile!, title: e.target.value })}
|
||||||
|
placeholder="e.g., Account Executive"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Company</label>
|
||||||
|
<input
|
||||||
|
value={profile?.company || ''}
|
||||||
|
onChange={(e) => setProfile({ ...profile!, company: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>Phone</label>
|
||||||
|
<input
|
||||||
|
value={profile?.phone || ''}
|
||||||
|
onChange={(e) => setProfile({ ...profile!, phone: e.target.value })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Signature */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-4">
|
||||||
|
<h2 className="font-semibold text-slate-900">Email Signature</h2>
|
||||||
|
<textarea
|
||||||
|
value={profile?.emailSignature || ''}
|
||||||
|
onChange={(e) => setProfile({ ...profile!, emailSignature: e.target.value })}
|
||||||
|
rows={6}
|
||||||
|
placeholder="Your email signature (supports plain text)..."
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
{profile?.emailSignature && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium text-slate-500 mb-2">Preview:</p>
|
||||||
|
<div className="p-4 bg-slate-50 rounded-lg text-sm whitespace-pre-wrap border border-slate-200">
|
||||||
|
{profile.emailSignature}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <LoadingSpinner size="sm" className="text-white" /> : <Save className="w-4 h-4" />}
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
{success && (
|
||||||
|
<span className="text-sm text-emerald-600 font-medium animate-fade-in">✓ Saved successfully</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/stores/auth.ts
Normal file
72
src/stores/auth.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
setUser: (user: User | null) => void;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
checkSession: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
isLoading: true,
|
||||||
|
isAuthenticated: false,
|
||||||
|
|
||||||
|
setUser: (user) => set({
|
||||||
|
user,
|
||||||
|
isAuthenticated: !!user,
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
login: async (email, password) => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
await api.login(email, password);
|
||||||
|
const session = await api.getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
set({ user: session.user, isAuthenticated: true, isLoading: false });
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to get session');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
set({ isLoading: false });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} finally {
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
checkSession: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const session = await api.getSession();
|
||||||
|
if (session?.user) {
|
||||||
|
set({ user: session.user, isAuthenticated: true, isLoading: false });
|
||||||
|
} else {
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'network-auth-storage',
|
||||||
|
partialize: (state) => ({ user: state.user }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
87
src/stores/clients.ts
Normal file
87
src/stores/clients.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Client, ClientCreate } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface ClientsState {
|
||||||
|
clients: Client[];
|
||||||
|
selectedClient: Client | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
searchQuery: string;
|
||||||
|
selectedTag: string | null;
|
||||||
|
|
||||||
|
setSearchQuery: (query: string) => void;
|
||||||
|
setSelectedTag: (tag: string | null) => void;
|
||||||
|
fetchClients: () => Promise<void>;
|
||||||
|
fetchClient: (id: string) => Promise<void>;
|
||||||
|
createClient: (data: ClientCreate) => Promise<Client>;
|
||||||
|
updateClient: (id: string, data: Partial<ClientCreate>) => Promise<void>;
|
||||||
|
deleteClient: (id: string) => Promise<void>;
|
||||||
|
markContacted: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useClientsStore = create<ClientsState>()((set, get) => ({
|
||||||
|
clients: [],
|
||||||
|
selectedClient: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
searchQuery: '',
|
||||||
|
selectedTag: null,
|
||||||
|
|
||||||
|
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||||
|
setSelectedTag: (tag) => set({ selectedTag: tag }),
|
||||||
|
|
||||||
|
fetchClients: async () => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const { searchQuery, selectedTag } = get();
|
||||||
|
const clients = await api.getClients({
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
tag: selectedTag || undefined,
|
||||||
|
});
|
||||||
|
set({ clients, isLoading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchClient: async (id) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const client = await api.getClient(id);
|
||||||
|
set({ selectedClient: client, isLoading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createClient: async (data) => {
|
||||||
|
const client = await api.createClient(data);
|
||||||
|
set((state) => ({ clients: [...state.clients, client] }));
|
||||||
|
return client;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateClient: async (id, data) => {
|
||||||
|
const updated = await api.updateClient(id, data);
|
||||||
|
set((state) => ({
|
||||||
|
clients: state.clients.map((c) => (c.id === id ? updated : c)),
|
||||||
|
selectedClient: state.selectedClient?.id === id ? updated : state.selectedClient,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteClient: async (id) => {
|
||||||
|
await api.deleteClient(id);
|
||||||
|
set((state) => ({
|
||||||
|
clients: state.clients.filter((c) => c.id !== id),
|
||||||
|
selectedClient: state.selectedClient?.id === id ? null : state.selectedClient,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
markContacted: async (id) => {
|
||||||
|
const updated = await api.markContacted(id);
|
||||||
|
set((state) => ({
|
||||||
|
clients: state.clients.map((c) => (c.id === id ? updated : c)),
|
||||||
|
selectedClient: state.selectedClient?.id === id ? updated : state.selectedClient,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
84
src/stores/emails.ts
Normal file
84
src/stores/emails.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Email } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface EmailsState {
|
||||||
|
emails: Email[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isGenerating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
statusFilter: string | null;
|
||||||
|
|
||||||
|
setStatusFilter: (status: string | null) => void;
|
||||||
|
fetchEmails: (params?: { status?: string; clientId?: string }) => Promise<void>;
|
||||||
|
generateEmail: (clientId: string, purpose: string, provider?: 'anthropic' | 'openai') => Promise<Email>;
|
||||||
|
generateBirthdayEmail: (clientId: string, provider?: string) => Promise<Email>;
|
||||||
|
updateEmail: (id: string, data: { subject?: string; content?: string }) => Promise<void>;
|
||||||
|
sendEmail: (id: string) => Promise<void>;
|
||||||
|
deleteEmail: (id: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEmailsStore = create<EmailsState>()((set) => ({
|
||||||
|
emails: [],
|
||||||
|
isLoading: false,
|
||||||
|
isGenerating: false,
|
||||||
|
error: null,
|
||||||
|
statusFilter: null,
|
||||||
|
|
||||||
|
setStatusFilter: (status) => set({ statusFilter: status }),
|
||||||
|
|
||||||
|
fetchEmails: async (params) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const emails = await api.getEmails(params);
|
||||||
|
set({ emails, isLoading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateEmail: async (clientId, purpose, provider) => {
|
||||||
|
set({ isGenerating: true, error: null });
|
||||||
|
try {
|
||||||
|
const email = await api.generateEmail({ clientId, purpose, provider });
|
||||||
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
|
return email;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isGenerating: false });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
generateBirthdayEmail: async (clientId, provider) => {
|
||||||
|
set({ isGenerating: true, error: null });
|
||||||
|
try {
|
||||||
|
const email = await api.generateBirthdayEmail(clientId, provider);
|
||||||
|
set((state) => ({ emails: [email, ...state.emails], isGenerating: false }));
|
||||||
|
return email;
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isGenerating: false });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEmail: async (id, data) => {
|
||||||
|
const updated = await api.updateEmail(id, data);
|
||||||
|
set((state) => ({
|
||||||
|
emails: state.emails.map((e) => (e.id === id ? updated : e)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
sendEmail: async (id) => {
|
||||||
|
const sent = await api.sendEmail(id);
|
||||||
|
set((state) => ({
|
||||||
|
emails: state.emails.map((e) => (e.id === id ? sent : e)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEmail: async (id) => {
|
||||||
|
await api.deleteEmail(id);
|
||||||
|
set((state) => ({
|
||||||
|
emails: state.emails.filter((e) => e.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
72
src/stores/events.ts
Normal file
72
src/stores/events.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { Event, EventCreate } from '@/types';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
interface EventsState {
|
||||||
|
events: Event[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
typeFilter: string | null;
|
||||||
|
|
||||||
|
setTypeFilter: (type: string | null) => void;
|
||||||
|
fetchEvents: (params?: { clientId?: string; type?: string; upcoming?: number }) => Promise<void>;
|
||||||
|
createEvent: (data: EventCreate) => Promise<Event>;
|
||||||
|
updateEvent: (id: string, data: Partial<EventCreate>) => Promise<void>;
|
||||||
|
deleteEvent: (id: string) => Promise<void>;
|
||||||
|
syncAll: () => Promise<void>;
|
||||||
|
syncClient: (clientId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEventsStore = create<EventsState>()((set) => ({
|
||||||
|
events: [],
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
typeFilter: null,
|
||||||
|
|
||||||
|
setTypeFilter: (type) => set({ typeFilter: type }),
|
||||||
|
|
||||||
|
fetchEvents: async (params) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const events = await api.getEvents(params);
|
||||||
|
set({ events, isLoading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
createEvent: async (data) => {
|
||||||
|
const event = await api.createEvent(data);
|
||||||
|
set((state) => ({ events: [...state.events, event] }));
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateEvent: async (id, data) => {
|
||||||
|
const updated = await api.updateEvent(id, data);
|
||||||
|
set((state) => ({
|
||||||
|
events: state.events.map((e) => (e.id === id ? updated : e)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteEvent: async (id) => {
|
||||||
|
await api.deleteEvent(id);
|
||||||
|
set((state) => ({
|
||||||
|
events: state.events.filter((e) => e.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
syncAll: async () => {
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
await api.syncAllEvents();
|
||||||
|
const events = await api.getEvents();
|
||||||
|
set({ events, isLoading: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
set({ error: err.message, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncClient: async (clientId) => {
|
||||||
|
await api.syncClientEvents(clientId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
112
src/types/index.ts
Normal file
112
src/types/index.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
image?: string;
|
||||||
|
role?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
title?: string;
|
||||||
|
company?: string;
|
||||||
|
phone?: string;
|
||||||
|
emailSignature?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
company?: string;
|
||||||
|
role?: string;
|
||||||
|
industry?: string;
|
||||||
|
birthday?: string;
|
||||||
|
anniversary?: string;
|
||||||
|
interests?: string[];
|
||||||
|
family?: {
|
||||||
|
spouse?: string;
|
||||||
|
children?: string[];
|
||||||
|
};
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
lastContacted?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientCreate {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
street?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
zip?: string;
|
||||||
|
company?: string;
|
||||||
|
role?: string;
|
||||||
|
industry?: string;
|
||||||
|
birthday?: string;
|
||||||
|
anniversary?: string;
|
||||||
|
interests?: string[];
|
||||||
|
family?: {
|
||||||
|
spouse?: string;
|
||||||
|
children?: string[];
|
||||||
|
};
|
||||||
|
notes?: string;
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Event {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
clientId?: string;
|
||||||
|
client?: Client;
|
||||||
|
type: 'birthday' | 'anniversary' | 'followup' | 'custom';
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
recurring?: boolean;
|
||||||
|
reminderDays?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventCreate {
|
||||||
|
clientId?: string;
|
||||||
|
type: 'birthday' | 'anniversary' | 'followup' | 'custom';
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
recurring?: boolean;
|
||||||
|
reminderDays?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Email {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
clientId?: string;
|
||||||
|
client?: Client;
|
||||||
|
subject: string;
|
||||||
|
content: string;
|
||||||
|
status: 'draft' | 'sent' | 'failed';
|
||||||
|
purpose?: string;
|
||||||
|
provider?: string;
|
||||||
|
sentAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailGenerate {
|
||||||
|
clientId: string;
|
||||||
|
purpose: string;
|
||||||
|
provider?: 'anthropic' | 'openai';
|
||||||
|
}
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noImplicitAny": false
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
22
vite.config.ts
Normal file
22
vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user