mirror of
http://88.130.71.182:3000/BlitTech/Projet1-RealEstate.git
synced 2026-06-12 23:33:21 +00:00
Initial commit
This commit is contained in:
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
5
.bolt/prompt
Normal file
5
.bolt/prompt
Normal file
@@ -0,0 +1,5 @@
|
||||
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
|
||||
|
||||
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
|
||||
|
||||
Use icons from lucide-react for logos.
|
||||
3
.env
Normal file
3
.env
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJpb3ZpeXpqYXVjY3Z5YWttc3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDYxOTQ3MDAsImV4cCI6MjA2MTc3MDcwMH0.xNRhguD1i6NHdBkRdrLrUsrE9Fky9rGmvKNjVy8hyZQ
|
||||
VITE_SUPABASE_URL=https://bioviyzjauccvyakmstd.supabase.co
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# sb1-RealEstate
|
||||
|
||||
[Edit in StackBlitz next generation editor ⚡️](https://stackblitz.com/~/github.com/Dosseh91/sb1-RealEstate)
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!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>Agency Listings Marketplace</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4115
package-lock.json
generated
Normal file
4115
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "agency-listings-marketplace",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.11",
|
||||
"globals": "^15.9.0",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.3.0",
|
||||
"vite": "^5.4.2"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
90
src/App.tsx
Normal file
90
src/App.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import Navbar from './components/common/Navbar';
|
||||
import Footer from './components/common/Footer';
|
||||
import Home from './pages/Home';
|
||||
import Listings from './pages/Listings';
|
||||
import ListingDetail from './pages/ListingDetail';
|
||||
import Login from './pages/Login';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AgencyDashboard from './pages/AgencyDashboard';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { ListingProvider } from './contexts/ListingContext';
|
||||
|
||||
// Protected route component
|
||||
const ProtectedRoute: React.FC<{
|
||||
children: React.ReactNode;
|
||||
allowedRoles?: string[];
|
||||
}> = ({ children, allowedRoles }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Layout with navbar and footer
|
||||
const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
return (
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar />
|
||||
<main className="flex-grow">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// App component
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<ListingProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={<MainLayout><Home /></MainLayout>} />
|
||||
<Route path="/listings" element={<MainLayout><Listings /></MainLayout>} />
|
||||
<Route path="/listings/:id" element={<MainLayout><ListingDetail /></MainLayout>} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* Protected admin routes */}
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['admin']}>
|
||||
<MainLayout><AdminDashboard /></MainLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected agency routes */}
|
||||
<Route
|
||||
path="/agency/dashboard"
|
||||
element={
|
||||
<ProtectedRoute allowedRoles={['agency']}>
|
||||
<MainLayout><AgencyDashboard /></MainLayout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Redirect unknown routes to home */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ListingProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
291
src/components/admin/AgencyManagement.tsx
Normal file
291
src/components/admin/AgencyManagement.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle, XCircle, Eye, MessageCircle } from 'lucide-react';
|
||||
import Card, { CardContent, CardHeader } from '../common/Card';
|
||||
import Button from '../common/Button';
|
||||
import { Agency } from '../../types';
|
||||
import { agencies as mockAgencies } from '../../data/mockData';
|
||||
|
||||
const AgencyManagement: React.FC = () => {
|
||||
const [agencies, setAgencies] = useState<Agency[]>(mockAgencies);
|
||||
const [selectedAgency, setSelectedAgency] = useState<Agency | null>(null);
|
||||
const [isViewingDetails, setIsViewingDetails] = useState(false);
|
||||
|
||||
const handleVerifyAgency = (id: string) => {
|
||||
setAgencies(
|
||||
agencies.map(agency =>
|
||||
agency.id === id ? { ...agency, verified: true } : agency
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleRevokeVerification = (id: string) => {
|
||||
setAgencies(
|
||||
agencies.map(agency =>
|
||||
agency.id === id ? { ...agency, verified: false } : agency
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleViewDetails = (agency: Agency) => {
|
||||
setSelectedAgency(agency);
|
||||
setIsViewingDetails(true);
|
||||
};
|
||||
|
||||
const handleCloseDetails = () => {
|
||||
setIsViewingDetails(false);
|
||||
setSelectedAgency(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-primary-800">Agency Management</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isViewingDetails && selectedAgency ? (
|
||||
<div className="animate-fade-in">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-primary-800">
|
||||
Agency Details
|
||||
</h3>
|
||||
<Button variant="outline" size="sm" onClick={handleCloseDetails}>
|
||||
Back to List
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-1">
|
||||
<div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
|
||||
{selectedAgency.logo ? (
|
||||
<img
|
||||
src={selectedAgency.logo}
|
||||
alt={selectedAgency.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400">
|
||||
No Logo
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-primary-600">Status:</span>
|
||||
<span className={`text-sm font-medium ${selectedAgency.verified ? 'text-success-600' : 'text-warning-600'}`}>
|
||||
{selectedAgency.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-primary-600">ID:</span>
|
||||
<span className="text-sm">{selectedAgency.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-primary-600">User ID:</span>
|
||||
<span className="text-sm">{selectedAgency.userId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
{selectedAgency.verified ? (
|
||||
<Button
|
||||
variant="error"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handleRevokeVerification(selectedAgency.id)}
|
||||
icon={<XCircle className="h-4 w-4" />}
|
||||
>
|
||||
Revoke Verification
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
fullWidth
|
||||
onClick={() => handleVerifyAgency(selectedAgency.id)}
|
||||
icon={<CheckCircle className="h-4 w-4" />}
|
||||
>
|
||||
Verify Agency
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
fullWidth
|
||||
icon={<MessageCircle className="h-4 w-4" />}
|
||||
>
|
||||
Contact Agency
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<h4 className="text-lg font-medium text-primary-800 mb-4">
|
||||
{selectedAgency.name}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-primary-700 mb-1">Description</h5>
|
||||
<p className="text-primary-600">{selectedAgency.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-primary-700 mb-1">Contact Information</h5>
|
||||
<ul className="space-y-2">
|
||||
<li className="flex">
|
||||
<span className="text-sm text-primary-600 w-20">Email:</span>
|
||||
<span className="text-sm">{selectedAgency.email}</span>
|
||||
</li>
|
||||
<li className="flex">
|
||||
<span className="text-sm text-primary-600 w-20">Phone:</span>
|
||||
<span className="text-sm">{selectedAgency.phone}</span>
|
||||
</li>
|
||||
<li className="flex">
|
||||
<span className="text-sm text-primary-600 w-20">Website:</span>
|
||||
<span className="text-sm">
|
||||
{selectedAgency.website ? (
|
||||
<a
|
||||
href={selectedAgency.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-600 hover:underline"
|
||||
>
|
||||
{selectedAgency.website}
|
||||
</a>
|
||||
) : (
|
||||
'N/A'
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-primary-700 mb-1">Address</h5>
|
||||
<p className="text-sm text-primary-600">{selectedAgency.address}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h5 className="text-sm font-medium text-primary-700 mb-2">Verification Notes</h5>
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
rows={4}
|
||||
placeholder="Add notes about verification process..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Agency</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Email</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Phone</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Status</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agencies.map(agency => (
|
||||
<tr
|
||||
key={agency.id}
|
||||
className="border-b border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 flex-shrink-0 rounded-full overflow-hidden bg-gray-100 mr-3">
|
||||
{agency.logo ? (
|
||||
<img
|
||||
src={agency.logo}
|
||||
alt={agency.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-primary-800">{agency.name}</p>
|
||||
<p className="text-xs text-primary-500">{agency.id.substring(0, 8)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{agency.email}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{agency.phone}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
|
||||
agency.verified
|
||||
? 'bg-success-50 text-success-700'
|
||||
: 'bg-warning-50 text-warning-700'
|
||||
}`}
|
||||
>
|
||||
{agency.verified ? 'Verified' : 'Unverified'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleViewDetails(agency)}
|
||||
className="p-1"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="h-4 w-4 text-primary-600" />
|
||||
</Button>
|
||||
|
||||
{agency.verified ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRevokeVerification(agency.id)}
|
||||
className="p-1"
|
||||
title="Revoke Verification"
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-error-500" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleVerifyAgency(agency.id)}
|
||||
className="p-1"
|
||||
title="Verify Agency"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 text-success-500" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="p-1"
|
||||
title="Contact Agency"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 text-accent-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgencyManagement;
|
||||
228
src/components/admin/CategoryManagement.tsx
Normal file
228
src/components/admin/CategoryManagement.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PlusCircle, Edit, Trash2, Save, X } from 'lucide-react';
|
||||
import Button from '../common/Button';
|
||||
import Card, { CardContent, CardHeader } from '../common/Card';
|
||||
import { Category } from '../../types';
|
||||
import { categories as mockCategories, generateId } from '../../data/mockData';
|
||||
|
||||
const CategoryManagement: React.FC = () => {
|
||||
const [categories, setCategories] = useState<Category[]>(mockCategories);
|
||||
const [isAddingCategory, setIsAddingCategory] = useState(false);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
|
||||
const [newCategory, setNewCategory] = useState<Partial<Category>>({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'tag',
|
||||
slug: '',
|
||||
});
|
||||
|
||||
const handleAddCategory = () => {
|
||||
setIsAddingCategory(true);
|
||||
setNewCategory({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: 'tag',
|
||||
slug: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditCategory = (category: Category) => {
|
||||
setEditingCategoryId(category.id);
|
||||
setNewCategory({ ...category });
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsAddingCategory(false);
|
||||
setEditingCategoryId(null);
|
||||
};
|
||||
|
||||
const handleSaveCategory = () => {
|
||||
if (!newCategory.name || !newCategory.description) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAddingCategory) {
|
||||
const slug = newCategory.name?.toLowerCase().replace(/\s+/g, '-') || '';
|
||||
const category: Category = {
|
||||
id: generateId(),
|
||||
name: newCategory.name || '',
|
||||
description: newCategory.description || '',
|
||||
icon: newCategory.icon || 'tag',
|
||||
slug,
|
||||
};
|
||||
setCategories([...categories, category]);
|
||||
setIsAddingCategory(false);
|
||||
} else if (editingCategoryId) {
|
||||
setCategories(
|
||||
categories.map(cat =>
|
||||
cat.id === editingCategoryId
|
||||
? { ...cat, ...newCategory, slug: newCategory.slug || cat.slug }
|
||||
: cat
|
||||
)
|
||||
);
|
||||
setEditingCategoryId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCategory = (id: string) => {
|
||||
// In a real app, we would check if there are any listings using this category
|
||||
setCategories(categories.filter(cat => cat.id !== id));
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setNewCategory(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const iconOptions = [
|
||||
'tag', 'home', 'car', 'smartphone', 'briefcase', 'shopping-bag',
|
||||
'package', 'gift', 'coffee', 'music', 'book', 'camera', 'heart',
|
||||
'user', 'users', 'star', 'globe', 'map-pin', 'flag', 'bell'
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-primary-800">Category Management</h2>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleAddCategory}
|
||||
icon={<PlusCircle className="h-4 w-4" />}
|
||||
>
|
||||
Add Category
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{(isAddingCategory || editingCategoryId) && (
|
||||
<div className="mb-6 p-4 border border-gray-200 rounded-md bg-gray-50 animate-fade-in">
|
||||
<h3 className="text-lg font-medium text-primary-800 mb-4">
|
||||
{isAddingCategory ? 'Add New Category' : 'Edit Category'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Category Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={newCategory.name || ''}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="icon" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Icon
|
||||
</label>
|
||||
<select
|
||||
id="icon"
|
||||
name="icon"
|
||||
value={newCategory.icon || 'tag'}
|
||||
onChange={handleChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
>
|
||||
{iconOptions.map(icon => (
|
||||
<option key={icon} value={icon}>
|
||||
{icon.charAt(0).toUpperCase() + icon.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={newCategory.description || ''}
|
||||
onChange={handleChange}
|
||||
rows={2}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
icon={<X className="h-4 w-4" />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleSaveCategory}
|
||||
icon={<Save className="h-4 w-4" />}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Name</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Description</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Icon</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Slug</th>
|
||||
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map(category => (
|
||||
<tr
|
||||
key={category.id}
|
||||
className="border-b border-gray-200 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<span className="font-medium text-primary-800">{category.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{category.description}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{category.icon}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-primary-600">
|
||||
{category.slug}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditCategory(category)}
|
||||
className="p-1"
|
||||
>
|
||||
<Edit className="h-4 w-4 text-primary-600" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteCategory(category.id)}
|
||||
className="p-1"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-error-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryManagement;
|
||||
164
src/components/admin/ListingReviewCard.tsx
Normal file
164
src/components/admin/ListingReviewCard.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, CheckCircle, XCircle, MessageCircle } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Card, { CardContent, CardFooter } from '../common/Card';
|
||||
import Button from '../common/Button';
|
||||
import StatusBadge from '../common/StatusBadge';
|
||||
import { Listing } from '../../types';
|
||||
import { getCategoryById } from '../../data/mockData';
|
||||
import { useListings } from '../../contexts/ListingContext';
|
||||
|
||||
interface ListingReviewCardProps {
|
||||
listing: Listing;
|
||||
}
|
||||
|
||||
const ListingReviewCard: React.FC<ListingReviewCardProps> = ({ listing }) => {
|
||||
const { updateListingStatus } = useListings();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showRejectReason, setShowRejectReason] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const category = getCategoryById(listing.categoryId);
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
const handleApprove = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateListingStatus(listing.id, 'approved');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!showRejectReason) {
|
||||
setShowRejectReason(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rejectReason.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await updateListingStatus(listing.id, 'rejected');
|
||||
// In a real app, we would save the rejection reason as well
|
||||
console.log(`Rejection reason for listing ${listing.id}: ${rejectReason}`);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
setShowRejectReason(false);
|
||||
setRejectReason('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="h-full overflow-hidden">
|
||||
<div className="relative">
|
||||
<div className="aspect-w-16 aspect-h-9 overflow-hidden">
|
||||
<img
|
||||
src={listing.images[0] || 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
|
||||
alt={listing.title}
|
||||
className="w-full h-48 object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<StatusBadge status={listing.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="mb-2">
|
||||
<span className="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
{category?.name || 'Uncategorized'}
|
||||
</span>
|
||||
<span className="inline-block ml-2 text-primary-500 text-xs">
|
||||
ID: {listing.id.substring(0, 6)}...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-1">{listing.title}</h3>
|
||||
|
||||
<p className="text-primary-600 text-sm mb-3 line-clamp-2">
|
||||
{listing.description}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<span className="text-xl font-bold text-accent-700">
|
||||
{formatter.format(listing.price)}
|
||||
</span>
|
||||
<Link to={`/listings/${listing.id}`} className="text-accent-600 hover:text-accent-800 flex items-center">
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
<span className="text-sm">View</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{showRejectReason && (
|
||||
<div className="mt-2 mb-4 animate-fade-in">
|
||||
<label htmlFor="rejectReason" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Reason for rejection
|
||||
</label>
|
||||
<textarea
|
||||
id="rejectReason"
|
||||
value={rejectReason}
|
||||
onChange={e => setRejectReason(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-error-500"
|
||||
rows={3}
|
||||
placeholder="Please provide a reason for rejection..."
|
||||
></textarea>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="flex justify-between">
|
||||
{listing.status === 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant="success"
|
||||
size="sm"
|
||||
disabled={isUpdating}
|
||||
onClick={handleApprove}
|
||||
icon={<CheckCircle className="w-4 h-4" />}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
variant="error"
|
||||
size="sm"
|
||||
disabled={isUpdating}
|
||||
onClick={handleReject}
|
||||
icon={<XCircle className="w-4 h-4" />}
|
||||
>
|
||||
{showRejectReason ? 'Submit Rejection' : 'Reject'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{listing.status !== 'pending' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<MessageCircle className="w-4 h-4" />}
|
||||
>
|
||||
Contact Agency
|
||||
</Button>
|
||||
<Button
|
||||
variant={listing.status === 'rejected' ? 'outline' : 'primary'}
|
||||
size="sm"
|
||||
onClick={() => updateListingStatus(listing.id, 'pending')}
|
||||
>
|
||||
Reset Status
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingReviewCard;
|
||||
380
src/components/agency/ListingForm.tsx
Normal file
380
src/components/agency/ListingForm.tsx
Normal file
@@ -0,0 +1,380 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FileUp, Plus, Save, Trash2 } from 'lucide-react';
|
||||
import Card, { CardContent, CardHeader, CardFooter } from '../common/Card';
|
||||
import Button from '../common/Button';
|
||||
import { Listing, Category } from '../../types';
|
||||
import { useListings } from '../../contexts/ListingContext';
|
||||
import { categories, findAgencyByUserId } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ListingFormProps {
|
||||
initialListing?: Listing;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
const ListingForm: React.FC<ListingFormProps> = ({ initialListing, onSuccess }) => {
|
||||
const { user } = useAuth();
|
||||
const { addListing, updateListing } = useListings();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const agency = user ? findAgencyByUserId(user.id) : null;
|
||||
|
||||
const [formData, setFormData] = useState<Partial<Listing>>(
|
||||
initialListing || {
|
||||
title: '',
|
||||
description: '',
|
||||
price: 0,
|
||||
images: [],
|
||||
categoryId: '',
|
||||
location: '',
|
||||
status: 'pending',
|
||||
agencyId: agency?.id || '',
|
||||
}
|
||||
);
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [tempImageUrl, setTempImageUrl] = useState('');
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
|
||||
if (name === 'price') {
|
||||
setFormData({ ...formData, [name]: parseFloat(value) || 0 });
|
||||
} else {
|
||||
setFormData({ ...formData, [name]: value });
|
||||
}
|
||||
|
||||
// Clear error when user types
|
||||
if (errors[name]) {
|
||||
setErrors({ ...errors, [name]: '' });
|
||||
}
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
if (tempImageUrl && !formData.images?.includes(tempImageUrl)) {
|
||||
setFormData({
|
||||
...formData,
|
||||
images: [...(formData.images || []), tempImageUrl],
|
||||
});
|
||||
setTempImageUrl('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = (url: string) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
images: formData.images?.filter(image => image !== url) || [],
|
||||
});
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title?.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
}
|
||||
|
||||
if (!formData.description?.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
|
||||
if (!formData.price || formData.price <= 0) {
|
||||
newErrors.price = 'Price must be greater than 0';
|
||||
}
|
||||
|
||||
if (!formData.categoryId) {
|
||||
newErrors.categoryId = 'Category is required';
|
||||
}
|
||||
|
||||
if (!formData.location?.trim()) {
|
||||
newErrors.location = 'Location is required';
|
||||
}
|
||||
|
||||
if (!formData.images?.length) {
|
||||
newErrors.images = 'At least one image is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (initialListing) {
|
||||
await updateListing(formData as Listing);
|
||||
} else {
|
||||
await addListing(formData as Omit<Listing, 'id' | 'createdAt' | 'updatedAt'>);
|
||||
}
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save listing:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-primary-800">
|
||||
{initialListing ? 'Edit Listing' : 'Create New Listing'}
|
||||
</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="title" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Title <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.title
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
placeholder="Enter a descriptive title"
|
||||
/>
|
||||
{errors.title && <p className="mt-1 text-sm text-error-500">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Description <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description || ''}
|
||||
onChange={handleChange}
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.description
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
placeholder="Provide a detailed description of your listing"
|
||||
></textarea>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-error-500">{errors.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="price" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Price ($) <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="price"
|
||||
name="price"
|
||||
value={formData.price || ''}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
step="0.01"
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.price
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.price && <p className="mt-1 text-sm text-error-500">{errors.price}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="categoryId" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Category <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="categoryId"
|
||||
name="categoryId"
|
||||
value={formData.categoryId || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.categoryId
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map((category: Category) => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.categoryId && (
|
||||
<p className="mt-1 text-sm text-error-500">{errors.categoryId}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="location" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Location <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location || ''}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.location
|
||||
? 'border-error-500 focus:ring-error-500'
|
||||
: 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
placeholder="City, State or Online"
|
||||
/>
|
||||
{errors.location && <p className="mt-1 text-sm text-error-500">{errors.location}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Images <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<div className="border rounded-md p-4 bg-gray-50">
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tempImageUrl}
|
||||
onChange={(e) => setTempImageUrl(e.target.value)}
|
||||
placeholder="Enter image URL"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addImage}
|
||||
disabled={!tempImageUrl}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-primary-500">
|
||||
For this demo, please paste image URLs directly. In a real app, we would have file uploads.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formData.images && formData.images.length > 0 ? (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{formData.images.map((image, index) => (
|
||||
<div key={index} className="relative group">
|
||||
<img
|
||||
src={image}
|
||||
alt={`Listing image ${index + 1}`}
|
||||
className="h-24 w-full object-cover rounded-md"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeImage(image)}
|
||||
className="absolute top-1 right-1 bg-white bg-opacity-80 rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-error-500" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-md p-6 flex flex-col items-center justify-center">
|
||||
<FileUp className="h-10 w-10 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-center text-primary-500">
|
||||
No images added yet. Add image URLs above.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{errors.images && <p className="mt-1 text-sm text-error-500">{errors.images}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4 bg-primary-50">
|
||||
<h3 className="text-sm font-medium text-primary-800 mb-2">Listing Information</h3>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li className="flex justify-between">
|
||||
<span className="text-primary-600">Status:</span>
|
||||
<span className="font-medium">
|
||||
{initialListing ? initialListing.status : 'Will be submitted as Pending'}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-primary-600">Agency:</span>
|
||||
<span className="font-medium">{agency?.name || 'Unknown'}</span>
|
||||
</li>
|
||||
{initialListing && (
|
||||
<>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-primary-600">Created:</span>
|
||||
<span>{new Date(initialListing.createdAt).toLocaleDateString()}</span>
|
||||
</li>
|
||||
<li className="flex justify-between">
|
||||
<span className="text-primary-600">Last Updated:</span>
|
||||
<span>{new Date(initialListing.updatedAt).toLocaleDateString()}</span>
|
||||
</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<div className="mt-4 pt-4 border-t border-primary-200">
|
||||
<p className="text-xs text-primary-600">
|
||||
All listings require admin approval before they appear online. You'll be notified when your listing is approved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onSuccess && onSuccess()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
icon={<Save className="h-4 w-4" />}
|
||||
>
|
||||
{isSubmitting
|
||||
? 'Saving...'
|
||||
: initialListing
|
||||
? 'Update Listing'
|
||||
: 'Create Listing'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingForm;
|
||||
199
src/components/agency/MessageList.tsx
Normal file
199
src/components/agency/MessageList.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Mail, Check } from 'lucide-react';
|
||||
import Card, { CardContent, CardHeader, CardFooter } from '../common/Card';
|
||||
import Button from '../common/Button';
|
||||
import { Message, Listing } from '../../types';
|
||||
import { getMessagesByAgencyId, getListingById } from '../../data/mockData';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const MessageList: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
|
||||
const [replyText, setReplyText] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (user) {
|
||||
const agencyId = user.id;
|
||||
const agencyMessages = getMessagesByAgencyId(agencyId);
|
||||
setMessages(agencyMessages);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const markAsRead = (messageId: string) => {
|
||||
setMessages(prev =>
|
||||
prev.map(msg =>
|
||||
msg.id === messageId ? { ...msg, read: true } : msg
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectMessage = (message: Message) => {
|
||||
setSelectedMessage(message);
|
||||
if (!message.read) {
|
||||
markAsRead(message.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendReply = () => {
|
||||
// In a real app, we would send the reply to the server
|
||||
console.log(`Sending reply to message ${selectedMessage?.id}: ${replyText}`);
|
||||
setReplyText('');
|
||||
// Maybe show a success message
|
||||
};
|
||||
|
||||
const getListingTitle = (listingId: string) => {
|
||||
const listing = getListingById(listingId);
|
||||
return listing ? listing.title : 'Unknown Listing';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const unreadCount = messages.filter(msg => !msg.read).length;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex justify-between items-center">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-xl font-semibold text-primary-800">Messages</h2>
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs font-medium rounded-full bg-accent-100 text-accent-800">
|
||||
{unreadCount} unread
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedMessage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMessage(null)}
|
||||
icon={<ArrowLeft className="h-4 w-4" />}
|
||||
>
|
||||
Back to Messages
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedMessage ? (
|
||||
<div className="animate-fade-in">
|
||||
<div className="mb-4 pb-4 border-b border-gray-200">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-primary-800">
|
||||
From: {selectedMessage.name}
|
||||
</h3>
|
||||
<div className="text-sm text-primary-500">
|
||||
{selectedMessage.email} {selectedMessage.phone && `• ${selectedMessage.phone}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-primary-500">
|
||||
{formatDate(selectedMessage.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="text-sm font-medium text-primary-700">
|
||||
Re: {getListingTitle(selectedMessage.listingId)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-primary-800 whitespace-pre-line">
|
||||
{selectedMessage.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label htmlFor="reply" className="block text-sm font-medium text-primary-700 mb-2">
|
||||
Reply
|
||||
</label>
|
||||
<textarea
|
||||
id="reply"
|
||||
value={replyText}
|
||||
onChange={e => setReplyText(e.target.value)}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
placeholder="Type your reply here..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim()}
|
||||
icon={<ArrowRight className="h-4 w-4" />}
|
||||
>
|
||||
Send Reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : messages.length > 0 ? (
|
||||
<div className="divide-y divide-gray-200">
|
||||
{messages.map(message => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`py-4 hover:bg-gray-50 cursor-pointer transition-colors ${!message.read ? 'bg-primary-50' : ''}`}
|
||||
onClick={() => handleSelectMessage(message)}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className={`flex-shrink-0 mr-3 mt-1 ${!message.read ? 'text-accent-600' : 'text-gray-400'}`}>
|
||||
<Mail className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between">
|
||||
<p className={`text-sm font-medium ${!message.read ? 'text-primary-800' : 'text-primary-600'}`}>
|
||||
{message.name}
|
||||
</p>
|
||||
<p className="text-xs text-primary-500">
|
||||
{formatDate(message.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-primary-500 mb-1">
|
||||
Re: {getListingTitle(message.listingId)}
|
||||
</p>
|
||||
<p className="text-sm text-primary-600 truncate">
|
||||
{message.message}
|
||||
</p>
|
||||
</div>
|
||||
{!message.read && (
|
||||
<button
|
||||
className="ml-2 p-1 text-primary-400 hover:text-primary-600 rounded-full hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
markAsRead(message.id);
|
||||
}}
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Mail className="h-12 w-12 text-primary-300 mx-auto mb-3" />
|
||||
<h3 className="text-lg font-medium text-primary-700 mb-1">No Messages Yet</h3>
|
||||
<p className="text-primary-500">
|
||||
When clients contact you about your listings, messages will appear here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
{!selectedMessage && messages.length > 0 && (
|
||||
<CardFooter className="text-center">
|
||||
<p className="text-sm text-primary-500">
|
||||
Showing {messages.length} messages • {unreadCount} unread
|
||||
</p>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageList;
|
||||
63
src/components/common/Button.tsx
Normal file
63
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'success' | 'warning' | 'error' | 'link';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
fullWidth?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
fullWidth = false,
|
||||
icon,
|
||||
children,
|
||||
className = '',
|
||||
disabled = false,
|
||||
...props
|
||||
}) => {
|
||||
// Base classes
|
||||
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
// Size classes
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
}[size];
|
||||
|
||||
// Variant classes
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-700 text-white hover:bg-primary-800 focus:ring-primary-700 active:bg-primary-900 disabled:bg-primary-300',
|
||||
secondary: 'bg-accent-600 text-white hover:bg-accent-700 focus:ring-accent-600 active:bg-accent-800 disabled:bg-accent-300',
|
||||
outline: 'border border-primary-300 text-primary-700 hover:bg-primary-50 focus:ring-primary-500 active:bg-primary-100 disabled:text-primary-300',
|
||||
success: 'bg-success-500 text-white hover:bg-success-600 focus:ring-success-500 active:bg-success-700 disabled:bg-success-300',
|
||||
warning: 'bg-warning-500 text-white hover:bg-warning-600 focus:ring-warning-500 active:bg-warning-700 disabled:bg-warning-300',
|
||||
error: 'bg-error-500 text-white hover:bg-error-600 focus:ring-error-500 active:bg-error-700 disabled:bg-error-300',
|
||||
link: 'text-accent-600 hover:text-accent-800 hover:underline focus:ring-accent-500 p-0 disabled:text-accent-300',
|
||||
}[variant];
|
||||
|
||||
// Full width class
|
||||
const widthClass = fullWidth ? 'w-full' : '';
|
||||
|
||||
// Disabled class
|
||||
const disabledClass = disabled ? 'cursor-not-allowed opacity-70' : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${sizeClasses} ${variantClasses} ${widthClass} ${disabledClass} ${className}`}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2">{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
57
src/components/common/Card.tsx
Normal file
57
src/components/common/Card.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hoverable?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({ children, className = '', hoverable = false, onClick }) => {
|
||||
const baseClasses = 'bg-white rounded-lg shadow-sm overflow-hidden border border-gray-200 transition-all duration-200';
|
||||
const hoverClasses = hoverable ? 'hover:shadow-md hover:border-gray-300 cursor-pointer transform hover:-translate-y-1' : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} ${hoverClasses} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardContent: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`p-6 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
130
src/components/common/Footer.tsx
Normal file
130
src/components/common/Footer.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone } from 'lucide-react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-primary-900 text-white">
|
||||
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Company info */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">AgencyListings</h3>
|
||||
<p className="text-primary-300 text-sm">
|
||||
The premier marketplace for agency listings. Connecting professional agencies with interested clients.
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||
<Facebook className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||
<Twitter className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||
<Instagram className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||
<Linkedin className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/" className="text-primary-300 hover:text-white transition-colors">
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/listings" className="text-primary-300 hover:text-white transition-colors">
|
||||
Listings
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/categories" className="text-primary-300 hover:text-white transition-colors">
|
||||
Categories
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-primary-300 hover:text-white transition-colors">
|
||||
About Us
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* More links */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">For Agencies</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link to="/register" className="text-primary-300 hover:text-white transition-colors">
|
||||
Join as Agency
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/pricing" className="text-primary-300 hover:text-white transition-colors">
|
||||
Pricing
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/resources" className="text-primary-300 hover:text-white transition-colors">
|
||||
Resources
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/faq" className="text-primary-300 hover:text-white transition-colors">
|
||||
FAQ
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact info */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">Contact Us</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start">
|
||||
<Mail className="h-5 w-5 mr-2 text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-primary-300">support@agencylistings.example.com</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<Phone className="h-5 w-5 mr-2 text-primary-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-primary-300">+1 (555) 123-4567</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className="mt-6">
|
||||
<form className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Subscribe to our Newsletter</h4>
|
||||
<div className="flex">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="px-3 py-2 text-sm text-gray-900 bg-white border border-r-0 border-gray-300 rounded-l-md w-full focus:outline-none focus:ring-1 focus:ring-accent-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent-600 px-4 py-2 text-white text-sm font-medium rounded-r-md hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 focus:ring-offset-primary-900"
|
||||
>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-8 border-t border-primary-700">
|
||||
<p className="text-center text-primary-400 text-sm">
|
||||
© {new Date().getFullYear()} AgencyListings. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
273
src/components/common/Navbar.tsx
Normal file
273
src/components/common/Navbar.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Menu, X, ChevronDown, UserCircle, Search, Bell } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import Button from './Button';
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
const { user, logout, isRole } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
};
|
||||
|
||||
const toggleProfile = () => {
|
||||
setIsProfileOpen(!isProfileOpen);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
const navLinkClasses = 'px-3 py-2 text-primary-700 hover:text-accent-600 font-medium transition-colors';
|
||||
const navLinkActiveClasses = 'text-accent-700 border-b-2 border-accent-600';
|
||||
|
||||
return (
|
||||
<nav className="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
{/* Logo and desktop navigation */}
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Link to="/" className="text-2xl font-bold text-primary-800">
|
||||
AgencyListings
|
||||
</Link>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:items-center">
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={`${navLinkClasses} ${isActive('/') ? navLinkActiveClasses : ''}`}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/listings"
|
||||
className={`${navLinkClasses} ${isActive('/listings') ? navLinkActiveClasses : ''}`}
|
||||
>
|
||||
Listings
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className={`${navLinkClasses} ${isActive('/categories') ? navLinkActiveClasses : ''}`}
|
||||
>
|
||||
Categories
|
||||
</Link>
|
||||
{isRole('agency') && (
|
||||
<Link
|
||||
to="/agency/dashboard"
|
||||
className={`${navLinkClasses} ${isActive('/agency/dashboard') ? navLinkActiveClasses : ''}`}
|
||||
>
|
||||
My Dashboard
|
||||
</Link>
|
||||
)}
|
||||
{isRole('admin') && (
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className={`${navLinkClasses} ${isActive('/admin/dashboard') ? navLinkActiveClasses : ''}`}
|
||||
>
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop right section */}
|
||||
<div className="hidden sm:flex sm:items-center">
|
||||
<div className="relative mr-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search listings..."
|
||||
className="pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500 w-64"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
<div className="ml-3 relative">
|
||||
<div>
|
||||
<button
|
||||
onClick={toggleProfile}
|
||||
className="flex items-center max-w-xs bg-gray-100 rounded-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500"
|
||||
>
|
||||
<span className="sr-only">Open user menu</span>
|
||||
<UserCircle className="h-6 w-6 text-primary-700" />
|
||||
<span className="ml-2 font-medium hidden md:block">{user.name}</span>
|
||||
<ChevronDown className="ml-1 h-4 w-4 text-primary-600" />
|
||||
</button>
|
||||
</div>
|
||||
{isProfileOpen && (
|
||||
<div className="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg py-1 bg-white ring-1 ring-black ring-opacity-5 focus:outline-none z-10 animate-fade-in">
|
||||
<div className="px-4 py-2 text-xs text-gray-500">
|
||||
Signed in as <span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-200"></div>
|
||||
{isRole('agency') && (
|
||||
<Link
|
||||
to="/agency/profile"
|
||||
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
>
|
||||
Agency Profile
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-3">
|
||||
<Link to="/login">
|
||||
<Button variant="outline" size="sm">
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button size="sm">
|
||||
Register
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="flex items-center sm:hidden">
|
||||
{user && (
|
||||
<button className="p-2 mr-2 rounded-full text-primary-600 hover:text-primary-800 focus:outline-none">
|
||||
<Bell className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="p-2 rounded-md text-primary-600 hover:text-primary-800 focus:outline-none"
|
||||
>
|
||||
<span className="sr-only">Open main menu</span>
|
||||
{isMenuOpen ? (
|
||||
<X className="block h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="block h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{isMenuOpen && (
|
||||
<div className="sm:hidden bg-white border-t border-gray-200 animate-fade-in">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
<Link
|
||||
to="/"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/listings"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Listings
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Categories
|
||||
</Link>
|
||||
{isRole('agency') && (
|
||||
<Link
|
||||
to="/agency/dashboard"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
My Dashboard
|
||||
</Link>
|
||||
)}
|
||||
{isRole('admin') && (
|
||||
<Link
|
||||
to="/admin/dashboard"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Admin Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4 pb-3 border-t border-gray-200">
|
||||
{user ? (
|
||||
<>
|
||||
<div className="flex items-center px-4">
|
||||
<div className="flex-shrink-0">
|
||||
<UserCircle className="h-10 w-10 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="text-base font-medium text-primary-800">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-primary-500">
|
||||
{user.email}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 px-2 space-y-1">
|
||||
{isRole('agency') && (
|
||||
<Link
|
||||
to="/agency/profile"
|
||||
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
Agency Profile
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
setIsMenuOpen(false);
|
||||
}}
|
||||
className="block w-full text-left px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 flex flex-col space-y-2">
|
||||
<Link to="/login" onClick={() => setIsMenuOpen(false)}>
|
||||
<Button variant="outline" fullWidth>
|
||||
Sign in
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register" onClick={() => setIsMenuOpen(false)}>
|
||||
<Button fullWidth>
|
||||
Register
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
31
src/components/common/StatusBadge.tsx
Normal file
31
src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { ListingStatus } from '../../types';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: ListingStatus;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({ status, className = '' }) => {
|
||||
const baseClasses = 'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium';
|
||||
|
||||
const statusClasses = {
|
||||
pending: 'bg-warning-50 text-warning-700 border border-warning-300',
|
||||
approved: 'bg-success-50 text-success-700 border border-success-300',
|
||||
rejected: 'bg-error-50 text-error-700 border border-error-300',
|
||||
}[status];
|
||||
|
||||
const statusText = {
|
||||
pending: 'Pending Review',
|
||||
approved: 'Approved',
|
||||
rejected: 'Rejected',
|
||||
}[status];
|
||||
|
||||
return (
|
||||
<span className={`${baseClasses} ${statusClasses} ${className}`}>
|
||||
{statusText}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusBadge;
|
||||
229
src/components/listings/ContactForm.tsx
Normal file
229
src/components/listings/ContactForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Mail, Phone, Send } from 'lucide-react';
|
||||
import Card, { CardContent, CardHeader } from '../common/Card';
|
||||
import Button from '../common/Button';
|
||||
import { Listing } from '../../types';
|
||||
import { generateId, getCurrentTimestamp } from '../../data/mockData';
|
||||
|
||||
interface ContactFormProps {
|
||||
listing: Listing;
|
||||
}
|
||||
|
||||
const ContactForm: React.FC<ContactFormProps> = ({ listing }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: '',
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'Email is invalid';
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = 'Message is required';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
// In a real app, we would send this data to the server
|
||||
const message = {
|
||||
id: generateId(),
|
||||
listingId: listing.id,
|
||||
name: formData.name,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
message: formData.message,
|
||||
agencyId: listing.agencyId,
|
||||
createdAt: getCurrentTimestamp(),
|
||||
read: false,
|
||||
};
|
||||
|
||||
console.log('Sending message:', message);
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
|
||||
// Reset form after 5 seconds
|
||||
setTimeout(() => {
|
||||
setIsSubmitted(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: '',
|
||||
});
|
||||
}, 5000);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="sticky top-4 w-full">
|
||||
<CardHeader className="bg-primary-50">
|
||||
<h3 className="text-lg font-semibold text-primary-800">
|
||||
Contact About This Listing
|
||||
</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isSubmitted ? (
|
||||
<div className="text-center py-8 animate-fade-in">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-success-50 text-success-500 mb-4">
|
||||
<Send className="h-8 w-8" />
|
||||
</div>
|
||||
<h4 className="text-xl font-semibold text-primary-800 mb-2">Message Sent!</h4>
|
||||
<p className="text-primary-600">
|
||||
Thank you for your inquiry. The agency will get back to you soon.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Your Name <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.name ? 'border-error-500 focus:ring-error-500' : 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-error-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Email Address <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className={`w-full pl-10 pr-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.email ? 'border-error-500 focus:ring-error-500' : 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
{errors.email && <p className="mt-1 text-sm text-error-500">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="phone" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Phone Number (optional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Phone className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="tel"
|
||||
id="phone"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Message <span className="text-error-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={4}
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
|
||||
errors.message ? 'border-error-500 focus:ring-error-500' : 'border-gray-300 focus:ring-accent-500'
|
||||
}`}
|
||||
placeholder="I'm interested in this listing..."
|
||||
></textarea>
|
||||
{errors.message && <p className="mt-1 text-sm text-error-500">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={isSubmitting}
|
||||
className="group"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<span>Sending...</span>
|
||||
) : (
|
||||
<>
|
||||
<span>Send Message</span>
|
||||
<Send className="h-4 w-4 ml-2 transition-transform group-hover:translate-x-1" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-primary-500 text-center mt-4">
|
||||
By sending a message, you agree to our <a href="#" className="text-accent-600 hover:underline">Terms of Service</a> and <a href="#" className="text-accent-600 hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactForm;
|
||||
78
src/components/listings/ListingCard.tsx
Normal file
78
src/components/listings/ListingCard.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MapPin, Eye } from 'lucide-react';
|
||||
import Card from '../common/Card';
|
||||
import StatusBadge from '../common/StatusBadge';
|
||||
import { Listing } from '../../types';
|
||||
import { getCategoryById } from '../../data/mockData';
|
||||
|
||||
interface ListingCardProps {
|
||||
listing: Listing;
|
||||
showStatus?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ListingCard: React.FC<ListingCardProps> = ({
|
||||
listing,
|
||||
showStatus = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const { id, title, price, images, location, status, categoryId } = listing;
|
||||
const category = getCategoryById(categoryId);
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className={`h-full transition-all duration-300 hover:shadow-lg ${className}`}
|
||||
>
|
||||
<Link to={`/listings/${id}`}>
|
||||
<div className="relative">
|
||||
<div className="aspect-w-16 aspect-h-9 overflow-hidden">
|
||||
<img
|
||||
src={images[0] || 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
|
||||
alt={title}
|
||||
className="w-full h-48 object-cover transition-transform duration-500 hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
{showStatus && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 right-2 bg-white bg-opacity-80 rounded-full p-1.5 shadow-sm">
|
||||
<Eye className="h-4 w-4 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="mb-2">
|
||||
<span className="inline-block bg-primary-100 text-primary-800 text-xs font-medium px-2.5 py-0.5 rounded-full">
|
||||
{category?.name || 'Uncategorized'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-primary-800 line-clamp-1 mb-1">{title}</h3>
|
||||
|
||||
<div className="flex items-center text-primary-500 text-sm mb-2">
|
||||
<MapPin className="h-4 w-4 mr-1" /> {location}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex justify-between items-center">
|
||||
<span className="text-xl font-bold text-accent-700">
|
||||
{formatter.format(price)}
|
||||
</span>
|
||||
<span className="text-xs text-primary-500">Listed</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingCard;
|
||||
75
src/contexts/AuthContext.tsx
Normal file
75
src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { User, UserRole } from '../types';
|
||||
import { findUserByEmail } from '../data/mockData';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
isRole: (role: UserRole) => boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is stored in localStorage
|
||||
const storedUser = localStorage.getItem('user');
|
||||
if (storedUser) {
|
||||
setUser(JSON.parse(storedUser));
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
// In a real app, this would make an API call
|
||||
// For this demo, we'll use mock data
|
||||
setLoading(true);
|
||||
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
const foundUser = findUserByEmail(email);
|
||||
|
||||
if (foundUser) {
|
||||
// In a real app, we would verify the password here
|
||||
setUser(foundUser);
|
||||
localStorage.setItem('user', JSON.stringify(foundUser));
|
||||
} else {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
setUser(null);
|
||||
localStorage.removeItem('user');
|
||||
};
|
||||
|
||||
const isRole = (role: UserRole) => {
|
||||
return user?.role === role;
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, logout, isRole }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
110
src/contexts/ListingContext.tsx
Normal file
110
src/contexts/ListingContext.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { Listing, ListingStatus } from '../types';
|
||||
import { listings as mockListings, generateId, getCurrentTimestamp } from '../data/mockData';
|
||||
|
||||
interface ListingContextType {
|
||||
listings: Listing[];
|
||||
addListing: (listing: Omit<Listing, 'id' | 'createdAt' | 'updatedAt'>) => Promise<void>;
|
||||
updateListingStatus: (id: string, status: ListingStatus) => Promise<void>;
|
||||
updateListing: (listing: Listing) => Promise<void>;
|
||||
deleteListing: (id: string) => Promise<void>;
|
||||
getListingsByStatus: (status: ListingStatus) => Listing[];
|
||||
getListingsByAgency: (agencyId: string) => Listing[];
|
||||
getListingsByCategory: (categoryId: string) => Listing[];
|
||||
getListingById: (id: string) => Listing | undefined;
|
||||
}
|
||||
|
||||
const ListingContext = createContext<ListingContextType | undefined>(undefined);
|
||||
|
||||
interface ListingProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ListingProvider: React.FC<ListingProviderProps> = ({ children }) => {
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize with mock data
|
||||
setListings(mockListings);
|
||||
}, []);
|
||||
|
||||
const addListing = async (listing: Omit<Listing, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const newListing: Listing = {
|
||||
...listing,
|
||||
id: generateId(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
|
||||
setListings(prev => [...prev, newListing]);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const updateListingStatus = async (id: string, status: ListingStatus) => {
|
||||
setListings(prev =>
|
||||
prev.map(listing =>
|
||||
listing.id === id
|
||||
? { ...listing, status, updatedAt: getCurrentTimestamp() }
|
||||
: listing
|
||||
)
|
||||
);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const updateListing = async (updatedListing: Listing) => {
|
||||
setListings(prev =>
|
||||
prev.map(listing =>
|
||||
listing.id === updatedListing.id
|
||||
? { ...updatedListing, updatedAt: getCurrentTimestamp() }
|
||||
: listing
|
||||
)
|
||||
);
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const deleteListing = async (id: string) => {
|
||||
setListings(prev => prev.filter(listing => listing.id !== id));
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const getListingsByStatus = (status: ListingStatus) => {
|
||||
return listings.filter(listing => listing.status === status);
|
||||
};
|
||||
|
||||
const getListingsByAgency = (agencyId: string) => {
|
||||
return listings.filter(listing => listing.agencyId === agencyId);
|
||||
};
|
||||
|
||||
const getListingsByCategory = (categoryId: string) => {
|
||||
return listings.filter(listing => listing.categoryId === categoryId);
|
||||
};
|
||||
|
||||
const getListingById = (id: string) => {
|
||||
return listings.find(listing => listing.id === id);
|
||||
};
|
||||
|
||||
return (
|
||||
<ListingContext.Provider value={{
|
||||
listings,
|
||||
addListing,
|
||||
updateListingStatus,
|
||||
updateListing,
|
||||
deleteListing,
|
||||
getListingsByStatus,
|
||||
getListingsByAgency,
|
||||
getListingsByCategory,
|
||||
getListingById
|
||||
}}>
|
||||
{children}
|
||||
</ListingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useListings = () => {
|
||||
const context = useContext(ListingContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useListings must be used within a ListingProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
288
src/data/mockData.ts
Normal file
288
src/data/mockData.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { User, Agency, Category, Listing, Message } from '../types';
|
||||
|
||||
export const users: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'admin',
|
||||
verified: true,
|
||||
createdAt: '2023-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
email: 'agency1@example.com',
|
||||
name: 'Luxury Homes Agency',
|
||||
role: 'agency',
|
||||
verified: true,
|
||||
createdAt: '2023-01-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
email: 'agency2@example.com',
|
||||
name: 'CarSell Professional',
|
||||
role: 'agency',
|
||||
verified: true,
|
||||
createdAt: '2023-01-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
email: 'agency3@example.com',
|
||||
name: 'Tech Gear Pro',
|
||||
role: 'agency',
|
||||
verified: false,
|
||||
createdAt: '2023-01-04T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const agencies: Agency[] = [
|
||||
{
|
||||
id: '1',
|
||||
userId: '2',
|
||||
name: 'Luxury Homes Agency',
|
||||
description: 'We specialize in high-end real estate properties for discerning clients.',
|
||||
logo: 'https://images.pexels.com/photos/106399/pexels-photo-106399.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
address: '123 Luxury Lane, Beverly Hills, CA 90210',
|
||||
phone: '+1 (123) 456-7890',
|
||||
email: 'info@luxuryhomes.example.com',
|
||||
website: 'https://luxuryhomes.example.com',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
userId: '3',
|
||||
name: 'CarSell Professional',
|
||||
description: 'Premier automotive sales agency with a wide selection of vehicles.',
|
||||
logo: 'https://images.pexels.com/photos/120049/pexels-photo-120049.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
address: '456 Auto Drive, Detroit, MI 48226',
|
||||
phone: '+1 (234) 567-8901',
|
||||
email: 'sales@carsell.example.com',
|
||||
website: 'https://carsell.example.com',
|
||||
verified: true,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
userId: '4',
|
||||
name: 'Tech Gear Pro',
|
||||
description: 'Latest technology gadgets and electronics from trusted brands.',
|
||||
logo: 'https://images.pexels.com/photos/1337753/pexels-photo-1337753.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
address: '789 Tech Ave, San Francisco, CA 94107',
|
||||
phone: '+1 (345) 678-9012',
|
||||
email: 'sales@techgear.example.com',
|
||||
website: 'https://techgear.example.com',
|
||||
verified: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const categories: Category[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Real Estate',
|
||||
description: 'Homes, apartments, land, and commercial properties',
|
||||
icon: 'home',
|
||||
slug: 'real-estate',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Vehicles',
|
||||
description: 'Cars, motorcycles, boats, and other vehicles',
|
||||
icon: 'car',
|
||||
slug: 'vehicles',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Electronics',
|
||||
description: 'Computers, phones, TVs, and other electronic devices',
|
||||
icon: 'smartphone',
|
||||
slug: 'electronics',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Furniture',
|
||||
description: 'Home and office furniture, decor, and appliances',
|
||||
icon: 'sofa',
|
||||
slug: 'furniture',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Jobs',
|
||||
description: 'Job listings and career opportunities',
|
||||
icon: 'briefcase',
|
||||
slug: 'jobs',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Services',
|
||||
description: 'Professional services and skilled trades',
|
||||
icon: 'wrench',
|
||||
slug: 'services',
|
||||
},
|
||||
];
|
||||
|
||||
export const listings: Listing[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Luxury Penthouse with Ocean View',
|
||||
description: 'Beautiful 3-bedroom penthouse with panoramic ocean views, featuring high-end finishes, a gourmet kitchen, and private rooftop terrace.',
|
||||
price: 1500000,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/1396132/pexels-photo-1396132.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
'https://images.pexels.com/photos/1457847/pexels-photo-1457847.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'approved',
|
||||
agencyId: '1',
|
||||
categoryId: '1',
|
||||
location: 'Miami, FL',
|
||||
createdAt: '2023-02-01T00:00:00Z',
|
||||
updatedAt: '2023-02-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Modern Downtown Loft',
|
||||
description: 'Spacious industrial-style loft in the heart of downtown, featuring exposed brick walls, high ceilings, and modern amenities.',
|
||||
price: 850000,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/1643383/pexels-photo-1643383.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
'https://images.pexels.com/photos/1571470/pexels-photo-1571470.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'approved',
|
||||
agencyId: '1',
|
||||
categoryId: '1',
|
||||
location: 'New York, NY',
|
||||
createdAt: '2023-02-03T00:00:00Z',
|
||||
updatedAt: '2023-02-04T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '2023 Mercedes-Benz S-Class',
|
||||
description: 'Brand new 2023 Mercedes-Benz S-Class with all available luxury features and extended warranty.',
|
||||
price: 120000,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/120049/pexels-photo-120049.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
'https://images.pexels.com/photos/2365572/pexels-photo-2365572.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'approved',
|
||||
agencyId: '2',
|
||||
categoryId: '2',
|
||||
location: 'Los Angeles, CA',
|
||||
createdAt: '2023-02-05T00:00:00Z',
|
||||
updatedAt: '2023-02-06T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Tesla Model Y Performance',
|
||||
description: 'Like-new Tesla Model Y Performance with full self-driving capability, premium interior, and all available upgrades.',
|
||||
price: 65000,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/13861/IMG_3496bfree.jpg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
'https://images.pexels.com/photos/3729464/pexels-photo-3729464.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'pending',
|
||||
agencyId: '2',
|
||||
categoryId: '2',
|
||||
location: 'San Francisco, CA',
|
||||
createdAt: '2023-02-07T00:00:00Z',
|
||||
updatedAt: '2023-02-08T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Apple MacBook Pro 16" M2 Max',
|
||||
description: 'Latest Apple MacBook Pro with M2 Max chip, 32GB RAM, 1TB SSD, and AppleCare+ coverage.',
|
||||
price: 3499,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/303383/pexels-photo-303383.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'pending',
|
||||
agencyId: '3',
|
||||
categoryId: '3',
|
||||
location: 'Online',
|
||||
createdAt: '2023-02-09T00:00:00Z',
|
||||
updatedAt: '2023-02-10T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Samsung 85" Neo QLED 8K Smart TV',
|
||||
description: 'Immersive viewing experience with Samsung\'s latest 8K television featuring AI upscaling and premium sound system.',
|
||||
price: 5999,
|
||||
images: [
|
||||
'https://images.pexels.com/photos/6976103/pexels-photo-6976103.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||
],
|
||||
status: 'rejected',
|
||||
agencyId: '3',
|
||||
categoryId: '3',
|
||||
location: 'Online',
|
||||
createdAt: '2023-02-11T00:00:00Z',
|
||||
updatedAt: '2023-02-12T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
export const messages: Message[] = [
|
||||
{
|
||||
id: '1',
|
||||
listingId: '1',
|
||||
name: 'John Smith',
|
||||
email: 'john@example.com',
|
||||
phone: '+1 (123) 456-7890',
|
||||
message: 'I\'m interested in this property. Could I schedule a viewing for this weekend?',
|
||||
agencyId: '1',
|
||||
createdAt: '2023-03-01T00:00:00Z',
|
||||
read: false,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
listingId: '3',
|
||||
name: 'Sarah Johnson',
|
||||
email: 'sarah@example.com',
|
||||
phone: '+1 (234) 567-8901',
|
||||
message: 'Is this car still available? I would like to see it in person and take it for a test drive.',
|
||||
agencyId: '2',
|
||||
createdAt: '2023-03-02T00:00:00Z',
|
||||
read: true,
|
||||
},
|
||||
];
|
||||
|
||||
// Helper functions to interact with mock data
|
||||
export const generateId = () => uuidv4();
|
||||
|
||||
export const getCurrentTimestamp = () => new Date().toISOString();
|
||||
|
||||
// Auth related functions
|
||||
export const findUserByEmail = (email: string) => {
|
||||
return users.find(user => user.email === email);
|
||||
};
|
||||
|
||||
export const findAgencyByUserId = (userId: string) => {
|
||||
return agencies.find(agency => agency.userId === userId);
|
||||
};
|
||||
|
||||
// Listing related functions
|
||||
export const getListingsByAgencyId = (agencyId: string) => {
|
||||
return listings.filter(listing => listing.agencyId === agencyId);
|
||||
};
|
||||
|
||||
export const getListingById = (id: string) => {
|
||||
return listings.find(listing => listing.id === id);
|
||||
};
|
||||
|
||||
export const getListingsByStatus = (status: string) => {
|
||||
return listings.filter(listing => listing.status === status);
|
||||
};
|
||||
|
||||
export const getListingsByCategory = (categoryId: string) => {
|
||||
return listings.filter(listing => listing.categoryId === categoryId);
|
||||
};
|
||||
|
||||
// Category related functions
|
||||
export const getCategoryById = (id: string) => {
|
||||
return categories.find(category => category.id === id);
|
||||
};
|
||||
|
||||
// Message related functions
|
||||
export const getMessagesByAgencyId = (agencyId: string) => {
|
||||
return messages.filter(message => message.agencyId === agencyId);
|
||||
};
|
||||
|
||||
export const getMessagesByListingId = (listingId: string) => {
|
||||
return messages.filter(message => message.listingId === listingId);
|
||||
};
|
||||
3
src/index.css
Normal file
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
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 App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
210
src/pages/AdminDashboard.tsx
Normal file
210
src/pages/AdminDashboard.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Layers, Users, Settings, PlusCircle, Grid, List, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import Button from '../components/common/Button';
|
||||
import Card, { CardContent, CardHeader } from '../components/common/Card';
|
||||
import ListingReviewCard from '../components/admin/ListingReviewCard';
|
||||
import CategoryManagement from '../components/admin/CategoryManagement';
|
||||
import AgencyManagement from '../components/admin/AgencyManagement';
|
||||
import { useListings } from '../contexts/ListingContext';
|
||||
|
||||
const AdminDashboard: React.FC = () => {
|
||||
const { listings, getListingsByStatus } = useListings();
|
||||
const [activeTab, setActiveTab] = useState('pending');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
|
||||
const pendingListings = getListingsByStatus('pending');
|
||||
const approvedListings = getListingsByStatus('approved');
|
||||
const rejectedListings = getListingsByStatus('rejected');
|
||||
|
||||
const displayListings = activeTab === 'pending'
|
||||
? pendingListings
|
||||
: activeTab === 'approved'
|
||||
? approvedListings
|
||||
: rejectedListings;
|
||||
|
||||
const getListingCountText = () => {
|
||||
switch (activeTab) {
|
||||
case 'pending':
|
||||
return pendingListings.length === 1
|
||||
? '1 Pending Listing'
|
||||
: `${pendingListings.length} Pending Listings`;
|
||||
case 'approved':
|
||||
return approvedListings.length === 1
|
||||
? '1 Approved Listing'
|
||||
: `${approvedListings.length} Approved Listings`;
|
||||
case 'rejected':
|
||||
return rejectedListings.length === 1
|
||||
? '1 Rejected Listing'
|
||||
: `${rejectedListings.length} Rejected Listings`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<h1 className="text-3xl font-bold text-primary-800 mb-6">Admin Dashboard</h1>
|
||||
|
||||
{/* Dashboard stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<Card className="bg-primary-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-primary-100 text-primary-700 mr-4">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Pending Listings</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{pendingListings.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-success-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-success-100 text-success-700 mr-4">
|
||||
<CheckCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-success-600">Approved Listings</p>
|
||||
<p className="text-2xl font-bold text-success-800">{approvedListings.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-error-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-error-100 text-error-700 mr-4">
|
||||
<XCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-error-600">Rejected Listings</p>
|
||||
<p className="text-2xl font-bold text-error-800">{rejectedListings.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
|
||||
<div className="mb-4 md:mb-0">
|
||||
<div className="flex space-x-1 border border-gray-200 rounded-lg p-1 bg-gray-50 w-fit">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
activeTab === 'pending'
|
||||
? 'bg-white shadow-sm text-primary-800'
|
||||
: 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-4 w-4 mr-1.5" />
|
||||
Pending
|
||||
{pendingListings.length > 0 && (
|
||||
<span className={`ml-1.5 px-2 py-0.5 text-xs rounded-full ${
|
||||
activeTab === 'pending'
|
||||
? 'bg-primary-100 text-primary-800'
|
||||
: 'bg-primary-200 text-primary-800'
|
||||
}`}>
|
||||
{pendingListings.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('approved')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
activeTab === 'approved'
|
||||
? 'bg-white shadow-sm text-primary-800'
|
||||
: 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1.5" />
|
||||
Approved
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('rejected')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
activeTab === 'rejected'
|
||||
? 'bg-white shadow-sm text-primary-800'
|
||||
: 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<XCircle className="h-4 w-4 mr-1.5" />
|
||||
Rejected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex border border-gray-200 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? 'bg-gray-100 text-primary-800'
|
||||
: 'bg-white text-primary-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Grid className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? 'bg-gray-100 text-primary-800'
|
||||
: 'bg-white text-primary-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<List className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Listings */}
|
||||
<Card className="mb-12">
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:justify-between sm:items-center">
|
||||
<h2 className="text-xl font-semibold text-primary-800">{getListingCountText()}</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{displayListings.length > 0 ? (
|
||||
<div className={`grid ${viewMode === 'grid' ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1'} gap-6`}>
|
||||
{displayListings.map(listing => (
|
||||
<ListingReviewCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg text-primary-700 mb-2">No listings to show</p>
|
||||
<p className="text-primary-600 mb-4">
|
||||
{activeTab === 'pending'
|
||||
? 'There are no pending listings requiring your review.'
|
||||
: activeTab === 'approved'
|
||||
? 'No listings have been approved yet.'
|
||||
: 'No listings have been rejected.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="mb-12">
|
||||
<CategoryManagement />
|
||||
</div>
|
||||
|
||||
{/* Agencies */}
|
||||
<div className="mb-12">
|
||||
<AgencyManagement />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
200
src/pages/AgencyDashboard.tsx
Normal file
200
src/pages/AgencyDashboard.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import React, { useState } from 'react';
|
||||
import { PlusCircle, LayoutGrid, Package, MessageCircle, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import Button from '../components/common/Button';
|
||||
import Card, { CardContent, CardHeader } from '../components/common/Card';
|
||||
import ListingCard from '../components/listings/ListingCard';
|
||||
import ListingForm from '../components/agency/ListingForm';
|
||||
import MessageList from '../components/agency/MessageList';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useListings } from '../contexts/ListingContext';
|
||||
import { findAgencyByUserId } from '../data/mockData';
|
||||
|
||||
const AgencyDashboard: React.FC = () => {
|
||||
const { user } = useAuth();
|
||||
const { getListingsByAgency } = useListings();
|
||||
const [activeTab, setActiveTab] = useState('listings');
|
||||
const [isCreatingListing, setIsCreatingListing] = useState(false);
|
||||
const [editingListing, setEditingListing] = useState<string | null>(null);
|
||||
|
||||
// Find agency by user ID
|
||||
const agency = user ? findAgencyByUserId(user.id) : null;
|
||||
|
||||
// Get agency listings
|
||||
const agencyListings = agency ? getListingsByAgency(agency.id) : [];
|
||||
|
||||
// Count listings by status
|
||||
const pendingCount = agencyListings.filter(l => l.status === 'pending').length;
|
||||
const approvedCount = agencyListings.filter(l => l.status === 'approved').length;
|
||||
const rejectedCount = agencyListings.filter(l => l.status === 'rejected').length;
|
||||
|
||||
const handleEditComplete = () => {
|
||||
setIsCreatingListing(false);
|
||||
setEditingListing(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-800 mb-4 md:mb-0">{agency?.name} Dashboard</h1>
|
||||
{activeTab === 'listings' && !isCreatingListing && !editingListing && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreatingListing(true)}
|
||||
icon={<PlusCircle className="h-4 w-4" />}
|
||||
>
|
||||
Create New Listing
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agency stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<Card className="bg-primary-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-primary-100 text-primary-700 mr-4">
|
||||
<Package className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-primary-500">Total Listings</p>
|
||||
<p className="text-2xl font-bold text-primary-800">{agencyListings.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-warning-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-warning-100 text-warning-700 mr-4">
|
||||
<Clock className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-warning-600">Pending</p>
|
||||
<p className="text-2xl font-bold text-warning-800">{pendingCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-success-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-success-100 text-success-700 mr-4">
|
||||
<CheckCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-success-600">Approved</p>
|
||||
<p className="text-2xl font-bold text-success-800">{approvedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-error-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-3 rounded-full bg-error-100 text-error-700 mr-4">
|
||||
<XCircle className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-error-600">Rejected</p>
|
||||
<p className="text-2xl font-bold text-error-800">{rejectedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
{!isCreatingListing && !editingListing && (
|
||||
<div className="flex space-x-1 border border-gray-200 rounded-lg p-1 bg-gray-50 w-fit mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('listings')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
activeTab === 'listings'
|
||||
? 'bg-white shadow-sm text-primary-800'
|
||||
: 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4 mr-2" />
|
||||
Listings
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
|
||||
activeTab === 'messages'
|
||||
? 'bg-white shadow-sm text-primary-800'
|
||||
: 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
|
||||
}`}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4 mr-2" />
|
||||
Messages
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content based on tab */}
|
||||
{activeTab === 'listings' && (
|
||||
<>
|
||||
{isCreatingListing ? (
|
||||
<ListingForm onSuccess={handleEditComplete} />
|
||||
) : editingListing ? (
|
||||
<ListingForm
|
||||
initialListing={agencyListings.find(l => l.id === editingListing)}
|
||||
onSuccess={handleEditComplete}
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h2 className="text-xl font-semibold text-primary-800">My Listings</h2>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{agencyListings.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{agencyListings.map(listing => (
|
||||
<div key={listing.id} className="relative">
|
||||
<ListingCard listing={listing} showStatus />
|
||||
<div className="absolute top-2 left-2 flex space-x-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="px-2 py-1 bg-white bg-opacity-90 hover:bg-opacity-100"
|
||||
onClick={() => setEditingListing(listing.id)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-lg text-primary-700 mb-2">No listings yet</p>
|
||||
<p className="text-primary-600 mb-4">
|
||||
You haven't created any listings yet. Get started by creating your first listing.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setIsCreatingListing(true)}
|
||||
icon={<PlusCircle className="h-4 w-4" />}
|
||||
>
|
||||
Create New Listing
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'messages' && (
|
||||
<MessageList />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgencyDashboard;
|
||||
215
src/pages/Home.tsx
Normal file
215
src/pages/Home.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, ArrowRight, Tag, MapPin, Building2, Star } from 'lucide-react';
|
||||
import Button from '../components/common/Button';
|
||||
import Card from '../components/common/Card';
|
||||
import ListingCard from '../components/listings/ListingCard';
|
||||
import { categories, listings } from '../data/mockData';
|
||||
|
||||
const Home: React.FC = () => {
|
||||
// Only show approved listings
|
||||
const approvedListings = listings.filter(listing => listing.status === 'approved');
|
||||
const featuredListings = approvedListings.slice(0, 4);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero section */}
|
||||
<section className="relative bg-gradient-to-r from-primary-900 to-primary-800 text-white overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-20 bg-[url('https://images.pexels.com/photos/106399/pexels-photo-106399.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1')] bg-cover bg-center"></div>
|
||||
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24 md:py-32">
|
||||
<div className="max-w-3xl">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6 leading-tight">
|
||||
Discover Professional Listings from Verified Agencies
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl mb-8 text-primary-100">
|
||||
Browse quality listings across multiple categories, all verified by our admin team.
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-lg p-4 mb-8">
|
||||
<div className="flex flex-col md:flex-row space-y-3 md:space-y-0 md:space-x-3">
|
||||
<div className="relative flex-grow">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="What are you looking for?"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-md border-0 focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-3 text-gray-400 h-5 w-5" />
|
||||
</div>
|
||||
<div className="w-full md:w-48">
|
||||
<select className="w-full px-4 py-3 rounded-md border-0 focus:ring-2 focus:ring-accent-500">
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button variant="secondary" size="lg">
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<Link to="/listings">
|
||||
<Button size="lg">
|
||||
Browse All Listings
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/categories">
|
||||
<Button variant="outline" size="lg" className="!text-white border-white hover:bg-white/10">
|
||||
Explore Categories
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured categories */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary-800 mb-4">Browse by Category</h2>
|
||||
<p className="text-lg text-primary-600 max-w-2xl mx-auto">
|
||||
Explore our wide range of categories to find exactly what you're looking for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{categories.slice(0, 6).map(category => (
|
||||
<Link to={`/categories/${category.slug}`} key={category.id}>
|
||||
<Card hoverable className="h-full group">
|
||||
<div className="p-6 flex items-center">
|
||||
<div className="w-14 h-14 rounded-full bg-primary-100 flex items-center justify-center mr-4 group-hover:bg-accent-100 transition-colors">
|
||||
<Tag className="h-6 w-6 text-primary-600 group-hover:text-accent-600 transition-colors" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-1 group-hover:text-accent-700 transition-colors">
|
||||
{category.name}
|
||||
</h3>
|
||||
<p className="text-sm text-primary-600">
|
||||
{category.description}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRight className="ml-auto h-5 w-5 text-primary-400 group-hover:text-accent-500 transform group-hover:translate-x-1 transition-all" />
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-10">
|
||||
<Link to="/categories">
|
||||
<Button variant="outline">
|
||||
View All Categories
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Featured listings */}
|
||||
<section className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center mb-10">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-primary-800 mb-2">Featured Listings</h2>
|
||||
<p className="text-lg text-primary-600">
|
||||
Hand-picked quality listings from our verified agencies
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/listings">
|
||||
<Button variant="outline" className="hidden sm:flex">
|
||||
View All Listings
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{featuredListings.map(listing => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-10 sm:hidden">
|
||||
<Link to="/listings">
|
||||
<Button variant="outline">
|
||||
View All Listings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it works */}
|
||||
<section className="py-16 bg-gradient-to-br from-primary-50 to-accent-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-primary-800 mb-4">How It Works</h2>
|
||||
<p className="text-lg text-primary-600 max-w-2xl mx-auto">
|
||||
Our platform ensures quality listings through a simple but effective process
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-100 text-accent-600 flex items-center justify-center mx-auto mb-4">
|
||||
<Building2 className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-2">Verified Agencies</h3>
|
||||
<p className="text-primary-600">
|
||||
We only allow verified professional agencies to post listings on our platform, ensuring trustworthy offers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-100 text-accent-600 flex items-center justify-center mx-auto mb-4">
|
||||
<Star className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-2">Admin Approval</h3>
|
||||
<p className="text-primary-600">
|
||||
Every listing is reviewed by our admin team before being published to ensure quality and prevent fraud.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-100 text-accent-600 flex items-center justify-center mx-auto mb-4">
|
||||
<MapPin className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-2">Direct Contact</h3>
|
||||
<p className="text-primary-600">
|
||||
Easily contact agencies about listings that interest you through our built-in messaging system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-16 bg-primary-800 text-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="md:flex md:items-center md:justify-between">
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold mb-4">Are you an agency looking to post listings?</h2>
|
||||
<p className="text-xl text-primary-100 mb-6 md:mb-0">
|
||||
Register your agency today and start showcasing your listings to our audience.
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:w-1/3 text-center md:text-right">
|
||||
<Link to="/register">
|
||||
<Button variant="secondary" size="lg">
|
||||
Register as Agency
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
291
src/pages/ListingDetail.tsx
Normal file
291
src/pages/ListingDetail.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { MapPin, ArrowLeft, ChevronLeft, ChevronRight, Building } from 'lucide-react';
|
||||
import Button from '../components/common/Button';
|
||||
import ContactForm from '../components/listings/ContactForm';
|
||||
import StatusBadge from '../components/common/StatusBadge';
|
||||
import { Listing, Agency } from '../types';
|
||||
import { getListingById, getCategoryById, agencies } from '../data/mockData';
|
||||
|
||||
const ListingDetail: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [listing, setListing] = useState<Listing | null>(null);
|
||||
const [agency, setAgency] = useState<Agency | null>(null);
|
||||
const [activeImageIndex, setActiveImageIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// In a real app, this would be an API call
|
||||
if (id) {
|
||||
const foundListing = getListingById(id);
|
||||
|
||||
if (foundListing) {
|
||||
setListing(foundListing);
|
||||
|
||||
// Find the agency
|
||||
const foundAgency = agencies.find(a => a.id === foundListing.agencyId);
|
||||
setAgency(foundAgency || null);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-1/2 mb-12"></div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="md:col-span-2">
|
||||
<div className="aspect-w-16 aspect-h-9 bg-gray-200 rounded-lg mb-6"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-full mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="h-64 bg-gray-200 rounded mb-6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!listing) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 text-center">
|
||||
<h1 className="text-2xl font-bold text-primary-800 mb-4">Listing Not Found</h1>
|
||||
<p className="text-primary-600 mb-6">
|
||||
The listing you're looking for doesn't exist or has been removed.
|
||||
</p>
|
||||
<Link to="/listings">
|
||||
<Button variant="primary">
|
||||
Browse All Listings
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const category = getCategoryById(listing.categoryId);
|
||||
|
||||
const nextImage = () => {
|
||||
setActiveImageIndex((prevIndex) =>
|
||||
prevIndex === listing.images.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
};
|
||||
|
||||
const prevImage = () => {
|
||||
setActiveImageIndex((prevIndex) =>
|
||||
prevIndex === 0 ? listing.images.length - 1 : prevIndex - 1
|
||||
);
|
||||
};
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Back button and breadcrumbs */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-primary-600 hover:text-primary-800 flex items-center"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
<span>Back to listings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{/* Left column - listing details */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2 mb-2">
|
||||
<h1 className="text-3xl font-bold text-primary-800">{listing.title}</h1>
|
||||
<div className="flex items-center">
|
||||
<StatusBadge status={listing.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mb-2">
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
{category?.name || 'Uncategorized'}
|
||||
</span>
|
||||
<span className="inline-flex items-center text-primary-600 text-sm">
|
||||
<MapPin className="h-4 w-4 mr-1" /> {listing.location}
|
||||
</span>
|
||||
<span className="text-sm text-primary-500">
|
||||
Posted on {new Date(listing.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-accent-700 my-2">
|
||||
{formatter.format(listing.price)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Image gallery */}
|
||||
<div className="mb-8">
|
||||
<div className="relative bg-gray-100 rounded-lg overflow-hidden">
|
||||
<div className="aspect-w-16 aspect-h-9">
|
||||
<img
|
||||
src={listing.images[activeImageIndex] || 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1'}
|
||||
alt={listing.title}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{listing.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={prevImage}
|
||||
className="absolute left-2 top-1/2 transform -translate-y-1/2 bg-white bg-opacity-75 rounded-full p-2 hover:bg-opacity-100 focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6 text-primary-800" />
|
||||
</button>
|
||||
<button
|
||||
onClick={nextImage}
|
||||
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-white bg-opacity-75 rounded-full p-2 hover:bg-opacity-100 focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6 text-primary-800" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{listing.images.length > 1 && (
|
||||
<div className="mt-2 flex space-x-2 overflow-x-auto pb-2">
|
||||
{listing.images.map((image, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setActiveImageIndex(index)}
|
||||
className={`flex-shrink-0 w-20 h-20 rounded-md overflow-hidden border-2 ${
|
||||
activeImageIndex === index ? 'border-accent-500' : 'border-transparent'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={image}
|
||||
alt={`${listing.title} thumbnail ${index + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'https://images.pexels.com/photos/1546168/pexels-photo-1546168.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1';
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-4">Description</h3>
|
||||
<div className="prose text-primary-700 max-w-none">
|
||||
<p className="whitespace-pre-line">{listing.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agency info (mobile only) */}
|
||||
<div className="md:hidden mb-8">
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-4">Listed By</h3>
|
||||
{agency && (
|
||||
<div className="flex items-center p-4 border border-gray-200 rounded-lg">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full overflow-hidden flex-shrink-0">
|
||||
{agency.logo ? (
|
||||
<img
|
||||
src={agency.logo}
|
||||
alt={agency.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Building className="w-full h-full p-2 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="font-medium text-primary-800">
|
||||
{agency.name}
|
||||
{agency.verified && (
|
||||
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-success-100 text-success-800">
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
<p className="text-sm text-primary-600">{agency.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - contact form and agency info */}
|
||||
<div>
|
||||
{/* Agency info (desktop only) */}
|
||||
<div className="hidden md:block mb-6">
|
||||
<h3 className="text-xl font-semibold text-primary-800 mb-4">Listed By</h3>
|
||||
{agency && (
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="w-12 h-12 bg-gray-100 rounded-full overflow-hidden flex-shrink-0">
|
||||
{agency.logo ? (
|
||||
<img
|
||||
src={agency.logo}
|
||||
alt={agency.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<Building className="w-full h-full p-2 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<h4 className="font-medium text-primary-800">
|
||||
{agency.name}
|
||||
</h4>
|
||||
{agency.verified && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-success-100 text-success-800">
|
||||
Verified Agency
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-primary-600 mb-4">
|
||||
<p className="mb-1">{agency.address}</p>
|
||||
<p className="mb-1">{agency.phone}</p>
|
||||
<p>{agency.email}</p>
|
||||
</div>
|
||||
|
||||
{agency.website && (
|
||||
<a
|
||||
href={agency.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent-600 hover:text-accent-800 hover:underline text-sm flex items-center"
|
||||
>
|
||||
Visit Website
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contact form */}
|
||||
<ContactForm listing={listing} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingDetail;
|
||||
377
src/pages/Listings.tsx
Normal file
377
src/pages/Listings.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { ListFilter, Search, ChevronDown, X } from 'lucide-react';
|
||||
import ListingCard from '../components/listings/ListingCard';
|
||||
import Button from '../components/common/Button';
|
||||
import { Listing } from '../types';
|
||||
import { listings as mockListings, categories } from '../data/mockData';
|
||||
|
||||
const Listings: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
|
||||
const [listings, setListings] = useState<Listing[]>([]);
|
||||
const [filteredListings, setFilteredListings] = useState<Listing[]>([]);
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||
|
||||
// Parse filters from URL
|
||||
const categoryFilter = searchParams.get('category') || '';
|
||||
const priceMinFilter = searchParams.get('price_min') || '';
|
||||
const priceMaxFilter = searchParams.get('price_max') || '';
|
||||
const searchFilter = searchParams.get('search') || '';
|
||||
const sortBy = searchParams.get('sort') || 'newest';
|
||||
|
||||
// Form state
|
||||
const [filters, setFilters] = useState({
|
||||
category: categoryFilter,
|
||||
priceMin: priceMinFilter,
|
||||
priceMax: priceMaxFilter,
|
||||
search: searchFilter,
|
||||
});
|
||||
|
||||
// Initialize with mock data (only approved listings)
|
||||
useEffect(() => {
|
||||
const approvedListings = mockListings.filter(listing => listing.status === 'approved');
|
||||
setListings(approvedListings);
|
||||
}, []);
|
||||
|
||||
// Apply filters
|
||||
useEffect(() => {
|
||||
let result = [...listings];
|
||||
|
||||
// Apply search filter
|
||||
if (searchFilter) {
|
||||
const searchLower = searchFilter.toLowerCase();
|
||||
result = result.filter(listing =>
|
||||
listing.title.toLowerCase().includes(searchLower) ||
|
||||
listing.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply category filter
|
||||
if (categoryFilter) {
|
||||
result = result.filter(listing => listing.categoryId === categoryFilter);
|
||||
}
|
||||
|
||||
// Apply price filters
|
||||
if (priceMinFilter) {
|
||||
const min = parseFloat(priceMinFilter);
|
||||
result = result.filter(listing => listing.price >= min);
|
||||
}
|
||||
|
||||
if (priceMaxFilter) {
|
||||
const max = parseFloat(priceMaxFilter);
|
||||
result = result.filter(listing => listing.price <= max);
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === 'price_low') {
|
||||
result.sort((a, b) => a.price - b.price);
|
||||
} else if (sortBy === 'price_high') {
|
||||
result.sort((a, b) => b.price - a.price);
|
||||
} else if (sortBy === 'oldest') {
|
||||
result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// Default: newest first
|
||||
result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
setFilteredListings(result);
|
||||
}, [listings, categoryFilter, priceMinFilter, priceMaxFilter, searchFilter, sortBy]);
|
||||
|
||||
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFilters(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const applyFilters = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Update URL with filters
|
||||
const newSearchParams = new URLSearchParams();
|
||||
|
||||
if (filters.category) newSearchParams.set('category', filters.category);
|
||||
if (filters.priceMin) newSearchParams.set('price_min', filters.priceMin);
|
||||
if (filters.priceMax) newSearchParams.set('price_max', filters.priceMax);
|
||||
if (filters.search) newSearchParams.set('search', filters.search);
|
||||
if (sortBy) newSearchParams.set('sort', sortBy);
|
||||
|
||||
setSearchParams(newSearchParams);
|
||||
|
||||
// Close mobile filters
|
||||
setIsFiltersOpen(false);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
category: '',
|
||||
priceMin: '',
|
||||
priceMax: '',
|
||||
search: '',
|
||||
});
|
||||
setSearchParams(new URLSearchParams({ sort: sortBy }));
|
||||
};
|
||||
|
||||
const hasActiveFilters = categoryFilter || priceMinFilter || priceMaxFilter || searchFilter;
|
||||
|
||||
const handleSortChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const newSortValue = e.target.value;
|
||||
|
||||
// Update URL with new sort parameter but keep existing filters
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
newSearchParams.set('sort', newSortValue);
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-800 mb-2">Browse Listings</h1>
|
||||
<p className="text-lg text-primary-600">
|
||||
Explore our collection of quality, verified listings from professional agencies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search and filter bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div className="relative flex-grow max-w-2xl">
|
||||
<form onSubmit={applyFilters}>
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
value={filters.search}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Search listings..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
|
||||
<button type="submit" className="sr-only">Search</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
|
||||
className="md:hidden"
|
||||
icon={<ListFilter className="h-5 w-5" />}
|
||||
>
|
||||
Filters
|
||||
</Button>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant={hasActiveFilters ? 'primary' : 'outline'}
|
||||
onClick={() => setIsFiltersOpen(!isFiltersOpen)}
|
||||
icon={<ListFilter className="h-5 w-5" />}
|
||||
>
|
||||
{isFiltersOpen ? 'Hide Filters' : 'Show Filters'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<label htmlFor="sort" className="text-sm text-primary-700 whitespace-nowrap">
|
||||
Sort by:
|
||||
</label>
|
||||
<select
|
||||
id="sort"
|
||||
value={sortBy}
|
||||
onChange={handleSortChange}
|
||||
className="pl-3 pr-8 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="price_low">Price: Low to High</option>
|
||||
<option value="price_high">Price: High to Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter panel */}
|
||||
{isFiltersOpen && (
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-lg border border-gray-200 animate-fade-in">
|
||||
<form onSubmit={applyFilters}>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-primary-800">Filters</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearFilters}
|
||||
className="text-sm text-accent-600 hover:text-accent-800 flex items-center"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={filters.category}
|
||||
onChange={handleFilterChange}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priceMin" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Min Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="priceMin"
|
||||
name="priceMin"
|
||||
value={filters.priceMin}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Min Price"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="priceMax" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Max Price
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="priceMax"
|
||||
name="priceMax"
|
||||
value={filters.priceMax}
|
||||
onChange={handleFilterChange}
|
||||
placeholder="Max Price"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button type="submit" variant="secondary" fullWidth>
|
||||
Apply Filters
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active filters badges */}
|
||||
{hasActiveFilters && (
|
||||
<div className="mb-6 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-sm text-primary-700">Active Filters:</span>
|
||||
|
||||
{categoryFilter && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
{categories.find(c => c.id === categoryFilter)?.name || 'Category'}
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters(prev => ({ ...prev, category: '' }));
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('category');
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="ml-1 text-primary-500 hover:text-primary-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{priceMinFilter && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
Min: ${priceMinFilter}
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters(prev => ({ ...prev, priceMin: '' }));
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('price_min');
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="ml-1 text-primary-500 hover:text-primary-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{priceMaxFilter && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
Max: ${priceMaxFilter}
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters(prev => ({ ...prev, priceMax: '' }));
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('price_max');
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="ml-1 text-primary-500 hover:text-primary-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{searchFilter && (
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-primary-100 text-primary-800">
|
||||
Search: "{searchFilter}"
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters(prev => ({ ...prev, search: '' }));
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('search');
|
||||
setSearchParams(newParams);
|
||||
}}
|
||||
className="ml-1 text-primary-500 hover:text-primary-700"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results count */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm text-primary-600">
|
||||
Showing <span className="font-medium">{filteredListings.length}</span> results
|
||||
{hasActiveFilters ? ' with applied filters' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Listings grid */}
|
||||
{filteredListings.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{filteredListings.map(listing => (
|
||||
<ListingCard key={listing.id} listing={listing} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-gray-50 rounded-lg border border-gray-200">
|
||||
<h3 className="text-lg font-medium text-primary-800 mb-2">No Listings Found</h3>
|
||||
<p className="text-primary-600 mb-6">
|
||||
We couldn't find any listings matching your criteria.
|
||||
</p>
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear Filters
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Listings;
|
||||
185
src/pages/Login.tsx
Normal file
185
src/pages/Login.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { LogIn, Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import Button from '../components/common/Button';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Get the redirect path from location state or default to '/'
|
||||
const from = location.state?.from?.pathname || '/';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (!email.trim() || !password.trim()) {
|
||||
setError('Email and password are required');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await login(email, password);
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError('Invalid credentials. For demo, use: admin@example.com or agency1@example.com');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-bold text-primary-800 mb-2">Sign In</h1>
|
||||
<p className="text-primary-600">
|
||||
Sign in to your account to manage your listings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white py-8 px-4 shadow-md rounded-lg sm:px-10">
|
||||
{error && (
|
||||
<div className="mb-4 bg-error-50 text-error-700 p-4 rounded-md flex items-start">
|
||||
<AlertCircle className="h-5 w-5 mr-2 flex-shrink-0 mt-0.5" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Mail className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-primary-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-accent-600 focus:ring-accent-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-primary-700">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a href="#" className="font-medium text-accent-600 hover:text-accent-800">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
icon={<LogIn className="h-4 w-4" />}
|
||||
>
|
||||
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-primary-500">Demo Accounts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('admin@example.com');
|
||||
setPassword('password');
|
||||
}}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-primary-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500"
|
||||
>
|
||||
Admin: admin@example.com
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('agency1@example.com');
|
||||
setPassword('password');
|
||||
}}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm bg-white text-sm font-medium text-primary-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500"
|
||||
>
|
||||
Agency: agency1@example.com
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-primary-600">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="font-medium text-accent-600 hover:text-accent-800">
|
||||
Register as an agency
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
59
src/types/index.ts
Normal file
59
src/types/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export type UserRole = 'admin' | 'agency' | 'visitor';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
verified: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Agency {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo?: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website?: string;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export type ListingStatus = 'pending' | 'approved' | 'rejected';
|
||||
|
||||
export interface Listing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
images: string[];
|
||||
status: ListingStatus;
|
||||
agencyId: string;
|
||||
categoryId: string;
|
||||
location: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
listingId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
message: string;
|
||||
agencyId: string;
|
||||
createdAt: string;
|
||||
read: boolean;
|
||||
}
|
||||
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" />
|
||||
69
tailwind.config.js
Normal file
69
tailwind.config.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#F8FAFC',
|
||||
100: '#F1F5F9',
|
||||
200: '#E2E8F0',
|
||||
300: '#CBD5E1',
|
||||
400: '#94A3B8',
|
||||
500: '#64748B',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1E293B',
|
||||
900: '#0F172A',
|
||||
950: '#020617',
|
||||
},
|
||||
accent: {
|
||||
50: '#F0FDFA',
|
||||
100: '#CCFBF1',
|
||||
200: '#99F6E4',
|
||||
300: '#5EEAD4',
|
||||
400: '#2DD4BF',
|
||||
500: '#14B8A6',
|
||||
600: '#0D9488',
|
||||
700: '#0F766E',
|
||||
800: '#115E59',
|
||||
900: '#134E4A',
|
||||
950: '#042F2E',
|
||||
},
|
||||
success: {
|
||||
50: '#F0FDF4',
|
||||
500: '#22C55E',
|
||||
700: '#15803D',
|
||||
},
|
||||
warning: {
|
||||
50: '#FFFBEB',
|
||||
500: '#F59E0B',
|
||||
700: '#B45309',
|
||||
},
|
||||
error: {
|
||||
50: '#FEF2F2',
|
||||
500: '#EF4444',
|
||||
700: '#B91C1C',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-in-out',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"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" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
optimizeDeps: {
|
||||
exclude: ['lucide-react'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user