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

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" />