Initial commit

This commit is contained in:
Dosseh91
2025-05-02 16:17:34 +02:00
commit 67f6739005
41 changed files with 8606 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

5
.bolt/prompt Normal file
View 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
View File

@@ -0,0 +1,3 @@
VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJpb3ZpeXpqYXVjY3Z5YWttc3RkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDYxOTQ3MDAsImV4cCI6MjA2MTc3MDcwMH0.xNRhguD1i6NHdBkRdrLrUsrE9Fky9rGmvKNjVy8hyZQ
VITE_SUPABASE_URL=https://bioviyzjauccvyakmstd.supabase.co

24
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View 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
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

90
src/App.tsx Normal file
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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">
&copy; {new Date().getFullYear()} AgencyListings. All rights reserved.
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View 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;

View 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;

View 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;

View 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;

View 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;
};

View 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
View 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
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

10
src/main.tsx Normal file
View 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>
);

View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

69
tailwind.config.js Normal file
View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View 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
View 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'],
},
});