Heavy modifications. INtegrations of APIs

This commit is contained in:
belviskhoremk
2026-03-06 23:17:30 +00:00
commit d5db301bd2
61 changed files with 12630 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env
.bolt
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# sb1-RealEstate
[Edit in StackBlitz next generation editor ⚡️](https://stackblitz.com/~/github.com/Dosseh91/sb1-RealEstate)

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

14
index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Deals24Togo</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4125
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "agency-listings-marketplace",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.22.3",
"uuid": "^9.0.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

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

193
src/App.tsx Normal file
View File

@@ -0,0 +1,193 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom';
import Navbar from './components/common/Navbar';
import ErrorBoundary from './components/common/ErrorBoundary';
import BackToTop from './components/common/BackToTop';
import { api } from './services/api';
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';
import { I18nProvider } from './contexts/I18nContext';
import { ToastProvider } from './contexts/ToastContext';
import Register from './pages/Register';
import Categories from './pages/Categories';
import Profile from './pages/Profile';
import Favorites from './pages/Favorites';
import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword';
import About from './pages/About';
import AgencyPublicProfile from './pages/AgencyPublicProfile';
import NotFound from './pages/NotFound';
import VerifyEmail from './pages/VerifyEmail';
import SubscriptionPage from './pages/SubscriptionPage';
import PaymentReturn from './pages/PaymentReturn';
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">
<div className="flex flex-col items-center space-y-3">
<div className="w-10 h-10 border-4 border-accent-500 border-t-transparent rounded-full animate-spin" />
<p className="text-primary-600 text-sm">Loading...</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
};
const VerificationBanner: React.FC = () => {
const { user } = useAuth();
const [dismissed, setDismissed] = React.useState(false);
const [resending, setResending] = React.useState(false);
const [resent, setResent] = React.useState(false);
if (!user || user.verified || dismissed) return null;
const handleResend = async () => {
setResending(true);
try {
await api.auth.resendVerification();
setResent(true);
} catch {
// silent — email may not be configured in dev
setResent(true);
} finally {
setResending(false);
}
};
return (
<div className="bg-warning-50 border-b border-warning-200 px-4 py-2 flex items-center justify-between text-sm text-warning-800">
<span>
Please verify your email address to unlock all features.{' '}
{resent ? (
<span className="font-medium text-success-700">Verification email sent!</span>
) : (
<button
onClick={handleResend}
disabled={resending}
className="font-medium underline hover:no-underline disabled:opacity-60"
>
{resending ? 'Sending...' : 'Resend verification email'}
</button>
)}
</span>
<button onClick={() => setDismissed(true)} className="ml-4 text-warning-600 hover:text-warning-900"></button>
</div>
);
};
const MainLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="flex flex-col min-h-screen">
<Navbar />
<VerificationBanner />
<main className="flex-grow">{children}</main>
<Footer />
<BackToTop />
</div>
);
function App() {
return (
<I18nProvider>
<ToastProvider>
<AuthProvider>
<ListingProvider>
<Router>
<ErrorBoundary>
<Routes>
{/* Public routes */}
<Route path="/" element={<MainLayout><Home /></MainLayout>} />
<Route path="/listings" element={<MainLayout><Listings /></MainLayout>} />
<Route path="/categories" element={<MainLayout><Categories /></MainLayout>} />
<Route path="/listings/:id" element={<MainLayout><ListingDetail /></MainLayout>} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/about" element={<MainLayout><About /></MainLayout>} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/agencies/:id" element={<MainLayout><AgencyPublicProfile /></MainLayout>} />
{/* 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>
}
/>
<Route
path="/profile"
element={
<ProtectedRoute>
<MainLayout><Profile /></MainLayout>
</ProtectedRoute>
}
/>
<Route
path="/favorites"
element={
<ProtectedRoute>
<MainLayout><Favorites /></MainLayout>
</ProtectedRoute>
}
/>
{/* Payment routes */}
<Route path="/payment-return" element={<PaymentReturn />} />
<Route
path="/subscription"
element={
<ProtectedRoute allowedRoles={['agency']}>
<MainLayout><SubscriptionPage /></MainLayout>
</ProtectedRoute>
}
/>
<Route path="*" element={<MainLayout><NotFound /></MainLayout>} />
</Routes>
</ErrorBoundary>
</Router>
</ListingProvider>
</AuthProvider>
</ToastProvider>
</I18nProvider>
);
}
export default App;

View File

@@ -0,0 +1,323 @@
import React, { useState, useEffect } from 'react';
import { CheckCircle, XCircle, Eye, MessageCircle, Trash2, Search } from 'lucide-react';
import Card, { CardContent, CardHeader } from '../common/Card';
import Button from '../common/Button';
import { Agency } from '../../types';
import { api } from '../../services/api';
import { normalizeAgency } from '../../lib/normalizers';
import { useToast } from '../../contexts/ToastContext';
const AgencyManagement: React.FC = () => {
const { showToast } = useToast();
const [agencies, setAgencies] = useState<Agency[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selectedAgency, setSelectedAgency] = useState<Agency | null>(null);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [search, setSearch] = useState('');
useEffect(() => {
const load = async () => {
try {
const data = await api.agencies.list({ page_size: 100 });
setAgencies((data.agencies || data || []).map(normalizeAgency));
} catch (err: any) {
setError(err.message || 'Failed to load agencies');
} finally {
setLoading(false);
}
};
load();
}, []);
const setAction = (key: string, value: boolean) =>
setActionLoading(prev => ({ ...prev, [key]: value }));
const handleVerify = async (id: string) => {
setAction(`${id}-verify`, true);
try {
await api.agencies.verify(id);
setAgencies(prev => prev.map(a => (a.id === id ? { ...a, verified: true } : a)));
if (selectedAgency?.id === id) setSelectedAgency(prev => prev ? { ...prev, verified: true } : prev);
showToast('Agency verified!', 'success');
} catch (err: any) {
showToast(err.message || 'Failed to verify', 'error');
} finally {
setAction(`${id}-verify`, false);
}
};
const handleRevoke = async (id: string) => {
setAction(`${id}-revoke`, true);
try {
await api.agencies.revoke(id);
setAgencies(prev => prev.map(a => (a.id === id ? { ...a, verified: false } : a)));
if (selectedAgency?.id === id) setSelectedAgency(prev => prev ? { ...prev, verified: false } : prev);
showToast('Verification revoked', 'info');
} catch (err: any) {
showToast(err.message || 'Failed to revoke', 'error');
} finally {
setAction(`${id}-revoke`, false);
}
};
const handleDelete = async (id: string, name: string) => {
if (!window.confirm(`Delete agency "${name}"? This action cannot be undone.`)) return;
setAction(`${id}-delete`, true);
try {
await api.agencies.delete(id);
setAgencies(prev => prev.filter(a => a.id !== id));
if (selectedAgency?.id === id) setSelectedAgency(null);
showToast('Agency deleted', 'info');
} catch (err: any) {
showToast(err.message || 'Failed to delete', 'error');
} finally {
setAction(`${id}-delete`, false);
}
};
const filteredAgencies = search.trim()
? agencies.filter(a =>
a.name.toLowerCase().includes(search.toLowerCase()) ||
a.email.toLowerCase().includes(search.toLowerCase())
)
: agencies;
if (loading) {
return (
<Card>
<CardContent className="text-center py-8">
<p className="text-primary-500">Loading agencies...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h2 className="text-xl font-semibold text-primary-800">Agency Management</h2>
{!selectedAgency && (
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search by name or email…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
)}
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 p-3 bg-error-50 border border-error-200 rounded-md text-error-700 text-sm">
{error}
</div>
)}
{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={() => setSelectedAgency(null)}>
Back to List
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<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 text-sm">
<div className="flex justify-between">
<span className="text-primary-600">Status:</span>
<span className={`font-medium ${selectedAgency.verified ? 'text-success-600' : 'text-warning-600'}`}>
{selectedAgency.verified ? 'Verified' : 'Unverified'}
</span>
</div>
</div>
<div className="mt-6 space-y-2">
{selectedAgency.verified ? (
<Button
variant="error"
size="sm"
fullWidth
disabled={actionLoading[`${selectedAgency.id}-revoke`]}
onClick={() => handleRevoke(selectedAgency.id)}
icon={<XCircle className="h-4 w-4" />}
>
{actionLoading[`${selectedAgency.id}-revoke`] ? 'Revoking…' : 'Revoke Verification'}
</Button>
) : (
<Button
variant="success"
size="sm"
fullWidth
disabled={actionLoading[`${selectedAgency.id}-verify`]}
onClick={() => handleVerify(selectedAgency.id)}
icon={<CheckCircle className="h-4 w-4" />}
>
{actionLoading[`${selectedAgency.id}-verify`] ? 'Verifying…' : 'Verify Agency'}
</Button>
)}
<a href={`mailto:${selectedAgency.email}`}>
<Button variant="primary" size="sm" fullWidth icon={<MessageCircle className="h-4 w-4" />}>
Contact Agency
</Button>
</a>
<Button
variant="error"
size="sm"
fullWidth
disabled={actionLoading[`${selectedAgency.id}-delete`]}
onClick={() => handleDelete(selectedAgency.id, selectedAgency.name)}
icon={<Trash2 className="h-4 w-4" />}
>
{actionLoading[`${selectedAgency.id}-delete`] ? 'Deleting…' : 'Delete 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</h5>
<ul className="space-y-1 text-sm text-primary-600">
<li>Email: {selectedAgency.email}</li>
<li>Phone: {selectedAgency.phone}</li>
{selectedAgency.website && (
<li>
<a href={selectedAgency.website} target="_blank" rel="noopener noreferrer" className="text-accent-600 hover:underline">
{selectedAgency.website}
</a>
</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>
</div>
</div>
</div>
) : (
<>
{search.trim() && (
<p className="text-sm text-primary-500 mb-3">
{filteredAgencies.length} of {agencies.length} agencies
</p>
)}
<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>
{filteredAgencies.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-primary-500 text-sm">
No agencies match your search.
</td>
</tr>
) : (
filteredAgencies.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" />}
</div>
<p className="font-medium text-primary-800">{agency.name}</p>
</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={() => setSelectedAgency(agency)}
className="p-1"
title="View Details"
>
<Eye className="h-4 w-4 text-primary-600" />
</Button>
{agency.verified ? (
<Button
variant="outline"
size="sm"
disabled={actionLoading[`${agency.id}-revoke`]}
onClick={() => handleRevoke(agency.id)}
className="p-1"
title="Revoke Verification"
>
<XCircle className={`h-4 w-4 ${actionLoading[`${agency.id}-revoke`] ? 'text-gray-400 animate-spin' : 'text-error-500'}`} />
</Button>
) : (
<Button
variant="outline"
size="sm"
disabled={actionLoading[`${agency.id}-verify`]}
onClick={() => handleVerify(agency.id)}
className="p-1"
title="Verify Agency"
>
<CheckCircle className={`h-4 w-4 ${actionLoading[`${agency.id}-verify`] ? 'text-gray-400 animate-spin' : 'text-success-500'}`} />
</Button>
)}
<Button
variant="outline"
size="sm"
disabled={actionLoading[`${agency.id}-delete`]}
onClick={() => handleDelete(agency.id, agency.name)}
className="p-1"
title="Delete Agency"
>
<Trash2 className={`h-4 w-4 ${actionLoading[`${agency.id}-delete`] ? 'text-gray-400 animate-spin' : 'text-error-500'}`} />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</>
)}
</CardContent>
</Card>
);
};
export default AgencyManagement;

View File

@@ -0,0 +1,260 @@
import React, { useState, useEffect } 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 { api } from '../../services/api';
import { normalizeCategory } from '../../lib/normalizers';
import { useToast } from '../../contexts/ToastContext';
const CategoryManagement: React.FC = () => {
const { showToast } = useToast();
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [isAddingCategory, setIsAddingCategory] = useState(false);
const [editingCategoryId, setEditingCategoryId] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState<Partial<Category>>({
name: '', description: '', icon: 'tag', slug: '',
});
const iconOptions = [
'tag', 'home', 'car', 'smartphone', 'briefcase', 'shopping-bag',
'package', 'gift', 'coffee', 'music', 'book', 'camera', 'heart',
'user', 'users', 'star', 'globe', 'map-pin', 'flag', 'bell',
];
useEffect(() => {
const load = async () => {
try {
const data = await api.categories.list();
setCategories((data.categories || data || []).map(normalizeCategory));
} catch (err: any) {
setError(err.message || 'Failed to load categories');
} finally {
setLoading(false);
}
};
load();
}, []);
const handleAddCategory = () => {
setIsAddingCategory(true);
setEditingCategoryId(null);
setFormData({ name: '', description: '', icon: 'tag', slug: '' });
};
const handleEditCategory = (cat: Category) => {
setEditingCategoryId(cat.id);
setIsAddingCategory(false);
setFormData({ ...cat });
};
const handleCancel = () => {
setIsAddingCategory(false);
setEditingCategoryId(null);
setFormData({ name: '', description: '', icon: 'tag', slug: '' });
setError('');
};
const handleSave = async () => {
if (!formData.name?.trim() || !formData.description?.trim()) {
setError('Name and description are required.');
return;
}
setSaving(true);
setError('');
try {
const slug = formData.slug || formData.name!.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
const payload = {
name: formData.name,
description: formData.description,
icon: formData.icon || 'tag',
slug,
};
if (isAddingCategory) {
const result = await api.categories.create(payload);
setCategories(prev => [...prev, normalizeCategory(result)]);
setIsAddingCategory(false);
showToast('Category created!', 'success');
} else if (editingCategoryId) {
const result = await api.categories.update(editingCategoryId, payload);
setCategories(prev =>
prev.map(c => (c.id === editingCategoryId ? normalizeCategory(result) : c))
);
setEditingCategoryId(null);
showToast('Category updated!', 'success');
}
setFormData({ name: '', description: '', icon: 'tag', slug: '' });
} catch (err: any) {
setError(err.message || 'Failed to save category');
showToast(err.message || 'Failed to save category', 'error');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Delete this category? This may affect existing listings.')) return;
try {
await api.categories.delete(id);
setCategories(prev => prev.filter(c => c.id !== id));
showToast('Category deleted', 'info');
} catch (err: any) {
setError(err.message || 'Failed to delete category');
showToast(err.message || 'Failed to delete category', 'error');
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
if (loading) {
return (
<Card>
<CardContent className="text-center py-8">
<p className="text-primary-500">Loading categories...</p>
</CardContent>
</Card>
);
}
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>
{error && (
<div className="mb-4 p-3 bg-error-50 border border-error-200 rounded-md text-error-700 text-sm">
{error}
</div>
)}
{(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 className="block text-sm font-medium text-primary-700 mb-1">Category Name</label>
<input
type="text"
name="name"
value={formData.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 className="block text-sm font-medium text-primary-700 mb-1">Icon</label>
<select
name="icon"
value={formData.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>
<label className="block text-sm font-medium text-primary-700 mb-1">Slug (auto-generated if empty)</label>
<input
type="text"
name="slug"
value={formData.slug || ''}
onChange={handleChange}
placeholder="e.g. real-estate"
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 className="block text-sm font-medium text-primary-700 mb-1">Description</label>
<textarea
name="description"
value={formData.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"
/>
</div>
</div>
<div className="mt-4 flex justify-end space-x-2">
<Button variant="outline" size="sm" onClick={handleCancel} icon={<X className="h-4 w-4" />}>
Cancel
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleSave}
disabled={saving || !formData.name?.trim() || !formData.description?.trim()}
icon={<Save className="h-4 w-4" />}
>
{saving ? 'Saving...' : '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 text-right">Listings</th>
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Actions</th>
</tr>
</thead>
<tbody>
{categories.map(cat => (
<tr key={cat.id} className="border-b border-gray-200 hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-primary-800">{cat.name}</td>
<td className="px-4 py-3 text-sm text-primary-600">{cat.description}</td>
<td className="px-4 py-3 text-sm text-primary-600">{cat.icon}</td>
<td className="px-4 py-3 text-sm text-primary-600">{cat.slug}</td>
<td className="px-4 py-3 text-sm text-right">
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-700">
{cat.listingCount ?? 0}
</span>
</td>
<td className="px-4 py-3">
<div className="flex space-x-2">
<Button variant="outline" size="sm" onClick={() => handleEditCategory(cat)} className="p-1">
<Edit className="h-4 w-4 text-primary-600" />
</Button>
<Button variant="outline" size="sm" onClick={() => handleDelete(cat.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,189 @@
import React, { useState } from 'react';
import { Eye, CheckCircle, XCircle } 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 { useListings } from '../../contexts/ListingContext';
import { useToast } from '../../contexts/ToastContext';
interface ListingReviewCardProps {
listing: Listing;
}
const ListingReviewCard: React.FC<ListingReviewCardProps> = ({ listing }) => {
const { updateListingStatus } = useListings();
const { showToast } = useToast();
const [isUpdating, setIsUpdating] = useState(false);
const [showRejectReason, setShowRejectReason] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const formatter = new Intl.NumberFormat('fr-TG', {
style: 'currency',
currency: 'XOF',
minimumFractionDigits: 0,
});
const handleApprove = async () => {
setIsUpdating(true);
try {
await updateListingStatus(listing.id, 'approved');
showToast('Listing approved!', 'success');
} catch (err: any) {
showToast(err.message || 'Failed to approve', 'error');
} finally {
setIsUpdating(false);
}
};
const handleReject = async () => {
if (!showRejectReason) {
setShowRejectReason(true);
return;
}
if (!rejectReason.trim()) return;
setIsUpdating(true);
try {
await updateListingStatus(listing.id, 'rejected', rejectReason);
showToast('Listing rejected', 'info');
} catch (err: any) {
showToast(err.message || 'Failed to reject', 'error');
} finally {
setIsUpdating(false);
setShowRejectReason(false);
setRejectReason('');
}
};
return (
<Card className="h-full overflow-hidden">
<div className="relative">
<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}
loading="lazy"
className="w-full h-48 object-cover"
/>
<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">
{listing.categoryName || '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>
{listing.rejectionReason && (
<div className="mb-3 p-2 bg-error-50 border border-error-200 rounded text-xs text-error-700">
<strong>Rejection reason:</strong> {listing.rejectionReason}
</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..."
/>
</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>
{showRejectReason ? (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => { setShowRejectReason(false); setRejectReason(''); }}
>
Cancel
</Button>
<Button
variant="error"
size="sm"
disabled={isUpdating || !rejectReason.trim()}
onClick={handleReject}
icon={<XCircle className="w-4 h-4" />}
>
{isUpdating ? 'Rejecting…' : 'Submit'}
</Button>
</div>
) : (
<Button
variant="error"
size="sm"
disabled={isUpdating}
onClick={handleReject}
icon={<XCircle className="w-4 h-4" />}
>
Reject
</Button>
)}
</>
)}
{listing.status !== 'pending' && (
<Button
variant="outline"
size="sm"
disabled={isUpdating}
onClick={async () => {
setIsUpdating(true);
try {
await updateListingStatus(listing.id, 'pending');
showToast('Listing reset to pending', 'info');
} catch (err: any) {
showToast(err.message || 'Failed to reset listing', 'error');
} finally {
setIsUpdating(false);
}
}}
>
{isUpdating ? 'Resetting…' : 'Reset to Pending'}
</Button>
)}
</CardFooter>
</Card>
);
};
export default ListingReviewCard;

View File

@@ -0,0 +1,174 @@
import React, { useState, useEffect, useRef } from 'react';
import { Trash2, Shield, ChevronLeft, ChevronRight, Search } from 'lucide-react';
import Card, { CardContent, CardHeader } from '../common/Card';
import Button from '../common/Button';
import { User } from '../../types';
import { api } from '../../services/api';
import { useToast } from '../../contexts/ToastContext';
import { normalizeUser } from '../../lib/normalizers';
const PAGE_SIZE = 20;
const UserManagement: React.FC = () => {
const { showToast } = useToast();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [search, setSearch] = useState('');
const [searchInput, setSearchInput] = useState('');
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setLoading(true);
api.users.list({ page, page_size: PAGE_SIZE, ...(search ? { search } : {}) })
.then((data: any) => {
setUsers((data.users || data || []).map(normalizeUser));
setTotal(data.total ?? (data.users || data || []).length);
})
.catch((err: any) => setError(err.message || 'Failed to load users'))
.finally(() => setLoading(false));
}, [page, search]);
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setSearchInput(val);
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(() => {
setSearch(val);
setPage(1);
}, 400);
};
const handleDelete = async (id: string, name: string) => {
if (!window.confirm(`Delete user "${name}"? This action cannot be undone.`)) return;
try {
await api.users.delete(id);
setUsers(prev => prev.filter(u => u.id !== id));
setTotal(prev => prev - 1);
showToast('User deleted', 'info');
} catch (err: any) {
showToast(err.message || 'Failed to delete user', 'error');
}
};
const totalPages = Math.ceil(total / PAGE_SIZE);
if (loading) {
return (
<Card>
<CardContent className="text-center py-8">
<p className="text-primary-500">Loading users...</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h2 className="text-xl font-semibold text-primary-800">User Management</h2>
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchInput}
onChange={handleSearchChange}
placeholder="Search by name or email..."
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 w-56"
/>
</div>
<span className="text-sm text-primary-500 whitespace-nowrap">{total} total</span>
</div>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 p-3 bg-error-50 border border-error-200 rounded-md text-error-700 text-sm">
{error}
</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">Email</th>
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Role</th>
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Verified</th>
<th className="px-4 py-3 text-sm font-semibold text-primary-700">Actions</th>
</tr>
</thead>
<tbody>
{users.map(user => (
<tr key={user.id} className="border-b border-gray-200 hover:bg-gray-50">
<td className="px-4 py-3 font-medium text-primary-800">{user.name || '—'}</td>
<td className="px-4 py-3 text-sm text-primary-600">{user.email}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-primary-100 text-primary-800'
: user.role === 'agency'
? 'bg-accent-100 text-accent-800'
: 'bg-gray-100 text-gray-700'
}`}>
{user.role === 'admin' && <Shield className="h-3 w-3 mr-1" />}
{user.role}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 text-xs font-medium rounded-full ${
user.verified ? 'bg-success-50 text-success-700' : 'bg-warning-50 text-warning-700'
}`}>
{user.verified ? 'Verified' : 'Unverified'}
</span>
</td>
<td className="px-4 py-3">
{user.role !== 'admin' && (
<Button
variant="outline"
size="sm"
className="p-1"
title="Delete User"
onClick={() => handleDelete(user.id, user.name || user.email)}
>
<Trash2 className="h-4 w-4 text-error-500" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t border-gray-100">
<span className="text-sm text-primary-500">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
className="p-1.5 rounded-md border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => setPage(p => p + 1)}
disabled={page === totalPages}
className="p-1.5 rounded-md border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
)}
</CardContent>
</Card>
);
};
export default UserManagement;

View File

@@ -0,0 +1,519 @@
import React, { useState, useEffect, useRef } from 'react';
import { FileUp, Save, Trash2, Upload } 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 { useAuth } from '../../contexts/AuthContext';
import { api } from '../../services/api';
import { normalizeCategory } from '../../lib/normalizers';
import { useToast } from '../../contexts/ToastContext';
import { useI18n } from '../../contexts/I18nContext';
interface ListingFormProps {
initialListing?: Listing;
onSuccess?: () => void;
hasSubscription?: boolean;
}
const ListingForm: React.FC<ListingFormProps> = ({ initialListing, onSuccess, hasSubscription = true }) => {
const { t } = useI18n();
const { user, agency } = useAuth();
const { addListing, updateListing } = useListings();
const { showToast } = useToast();
const [isSubmitting, setIsSubmitting] = useState(false);
const [categories, setCategories] = useState<Category[]>([]);
const [uploadingImages, setUploadingImages] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); // 0-100
const fileInputRef = useRef<HTMLInputElement>(null);
const progressIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const uploadAbortRef = useRef<AbortController | null>(null);
// Clear interval and abort any in-flight upload on unmount
useEffect(() => {
return () => {
if (progressIntervalRef.current) clearInterval(progressIntervalRef.current);
uploadAbortRef.current?.abort();
};
}, []);
const cancelUpload = () => {
uploadAbortRef.current?.abort();
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
progressIntervalRef.current = null;
}
setUploadingImages(false);
setUploadProgress(0);
if (fileInputRef.current) fileInputRef.current.value = '';
};
const [formData, setFormData] = useState<{
title: string;
description: string;
price: number;
images: string[];
category_id: string;
location: string;
listing_type: string;
condition: string;
negotiable: boolean;
}>(
initialListing
? {
title: initialListing.title,
description: initialListing.description,
price: initialListing.price,
images: initialListing.images,
category_id: initialListing.categoryId,
location: initialListing.location,
listing_type: initialListing.listingType || 'sale',
condition: initialListing.condition || '',
negotiable: initialListing.negotiable || false,
}
: {
title: '',
description: '',
price: 0,
images: [],
category_id: '',
location: '',
listing_type: 'sale',
condition: '',
negotiable: false,
}
);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
api.categories.list()
.then((data: any) => setCategories((data.categories || data || []).map(normalizeCategory)))
.catch(console.error);
}, []);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
const { name, value, type } = e.target;
const checked = (e.target as HTMLInputElement).checked;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : name === 'price' ? parseFloat(value) || 0 : value,
}));
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
};
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (!files.length) return;
// Client-side file size check (10 MB per file)
const MAX_SIZE = 10 * 1024 * 1024;
const oversized = files.filter(f => f.size > MAX_SIZE);
if (oversized.length) {
setErrors(prev => ({
...prev,
images: `${oversized.map(f => f.name).join(', ')} exceed${oversized.length === 1 ? 's' : ''} the 10 MB limit`,
}));
if (fileInputRef.current) fileInputRef.current.value = '';
return;
}
setUploadingImages(true);
setUploadProgress(0);
progressIntervalRef.current = setInterval(() => {
setUploadProgress(prev => (prev < 85 ? prev + 10 : prev));
}, 300);
try {
const result = await api.uploads.images(files);
const urls: string[] = result.urls || result.map?.((r: any) => r.url) || [];
setUploadProgress(100);
setFormData(prev => ({ ...prev, images: [...prev.images, ...urls] }));
} catch (err: any) {
setErrors(prev => ({ ...prev, images: err.message || 'Failed to upload images' }));
} finally {
if (progressIntervalRef.current) {
clearInterval(progressIntervalRef.current);
progressIntervalRef.current = null;
}
setTimeout(() => {
setUploadingImages(false);
setUploadProgress(0);
}, 500);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const removeImage = (url: string) => {
setFormData(prev => ({ ...prev, images: prev.images.filter(img => img !== url) }));
api.uploads.delete(url).catch(() => {});
};
const validate = () => {
const newErrors: Record<string, string> = {};
if (!formData.title.trim()) newErrors.title = t.listingForm.titleRequired;
if (!formData.description.trim()) newErrors.description = t.listingForm.descriptionRequired;
if (!formData.price || formData.price <= 0) newErrors.price = t.listingForm.priceRequired;
if (!formData.category_id) newErrors.category_id = t.listingForm.categoryRequired;
if (!formData.location.trim()) newErrors.location = t.listingForm.locationRequired;
if (!formData.images.length) newErrors.images = t.listingForm.imageRequired;
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
const payload = {
title: formData.title,
description: formData.description,
price: formData.price,
images: formData.images,
category_id: formData.category_id,
location: formData.location,
listing_type: formData.listing_type,
condition: formData.condition || undefined,
negotiable: formData.negotiable,
};
if (initialListing) {
await updateListing(initialListing.id, payload);
showToast('Listing updated!', 'success');
} else {
await addListing(payload);
showToast('Listing created! Pending admin approval.', 'success');
}
onSuccess?.();
} catch (error: any) {
setErrors(prev => ({ ...prev, form: error.message || 'Failed to save listing' }));
showToast(error.message || 'Failed to save listing', 'error');
} finally {
setIsSubmitting(false);
}
};
return (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-primary-800">
{initialListing ? t.listingForm.editTitle : t.listingForm.createTitle}
</h2>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
{!hasSubscription && !initialListing && (
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-md text-warning-700 text-sm flex items-center justify-between">
<span>Un abonnement actif est requis pour publier des annonces.</span>
<a href="/subscription" className="ml-3 font-medium underline hover:no-underline flex-shrink-0">
S'abonner →
</a>
</div>
)}
{errors.form && (
<div className="mb-4 p-3 bg-error-50 border border-error-200 rounded-md text-error-700 text-sm">
{errors.form}
</div>
)}
<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">
{t.listingForm.titleLabel} <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={t.listingForm.titlePlaceholder}
/>
{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">
{t.listingForm.descriptionLabel} <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={t.listingForm.descriptionPlaceholder}
/>
{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">
{t.listingForm.price} <span className="text-error-500">*</span>
</label>
<input
type="number"
id="price"
name="price"
value={formData.price || ''}
onChange={handleChange}
min="0"
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="category_id" className="block text-sm font-medium text-primary-700 mb-1">
{t.listingForm.category} <span className="text-error-500">*</span>
</label>
<select
id="category_id"
name="category_id"
value={formData.category_id}
onChange={handleChange}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 ${
errors.category_id ? 'border-error-500 focus:ring-error-500' : 'border-gray-300 focus:ring-accent-500'
}`}
>
<option value="">{t.listingForm.selectCategory}</option>
{categories.map((cat: Category) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
{errors.category_id && <p className="mt-1 text-sm text-error-500">{errors.category_id}</p>}
</div>
</div>
<div>
<label htmlFor="location" className="block text-sm font-medium text-primary-700 mb-1">
{t.listingForm.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={t.listingForm.locationPlaceholder}
/>
{errors.location && <p className="mt-1 text-sm text-error-500">{errors.location}</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="listing_type" className="block text-sm font-medium text-primary-700 mb-1">
{t.listingForm.type}
</label>
<select
id="listing_type"
name="listing_type"
value={formData.listing_type}
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"
>
<option value="sale">{t.listingForm.forSale}</option>
<option value="rent">{t.listingForm.forRent}</option>
</select>
</div>
<div>
<label htmlFor="condition" className="block text-sm font-medium text-primary-700 mb-1">
{t.listingForm.condition}
</label>
<select
id="condition"
name="condition"
value={formData.condition}
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"
>
<option value="">{t.listingForm.notSpecified}</option>
<option value="new">{t.listingForm.conditionNew}</option>
<option value="used">{t.listingForm.conditionUsed}</option>
<option value="refurbished">{t.listingForm.conditionRefurbished}</option>
</select>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="negotiable"
name="negotiable"
checked={formData.negotiable}
onChange={handleChange}
className="h-4 w-4 text-accent-600 border-gray-300 rounded focus:ring-accent-500"
/>
<label htmlFor="negotiable" className="ml-2 block text-sm text-primary-700">
{t.listingForm.negotiable}
</label>
</div>
</div>
{/* Right column — images */}
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">
{t.listingForm.images} <span className="text-error-500">*</span>
</label>
<div className="border rounded-md p-4 bg-gray-50">
{/* File upload button */}
<div className="mb-3">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
multiple
className="hidden"
onChange={handleFileUpload}
id="image-upload"
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingImages}
onClick={() => fileInputRef.current?.click()}
icon={<Upload className="h-4 w-4" />}
>
{uploadingImages ? t.listingForm.uploading : t.listingForm.upload}
</Button>
{uploadingImages && (
<div className="mt-2">
<div className="flex justify-between items-center text-xs text-primary-500 mb-1">
<span>{t.listingForm.uploading}</span>
<div className="flex items-center gap-2">
<span>{uploadProgress}%</span>
<button
type="button"
onClick={cancelUpload}
className="text-error-500 hover:text-error-700 underline text-xs"
>
{t.listingForm.cancel}
</button>
</div>
</div>
<div className="w-full bg-gray-200 rounded-full h-1.5">
<div
className="bg-accent-600 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<p className="mt-1 text-xs text-primary-500">
{t.listingForm.imageHint}
</p>
</div>
{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">
{t.listingForm.noImages}
</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">{t.listingForm.listingInfo}</h3>
<ul className="space-y-2 text-sm">
<li className="flex justify-between">
<span className="text-primary-600">{t.listingForm.statusLabel}</span>
<span className="font-medium">
{initialListing ? initialListing.status : t.listingForm.pendingSubmit}
</span>
</li>
<li className="flex justify-between">
<span className="text-primary-600">{t.listingForm.agencyLabel}</span>
<span className="font-medium">{agency?.name || user?.name || 'Unknown'}</span>
</li>
{initialListing && (
<>
<li className="flex justify-between">
<span className="text-primary-600">{t.listingForm.created}</span>
<span>{new Date(initialListing.createdAt).toLocaleDateString()}</span>
</li>
{initialListing.updatedAt && (
<li className="flex justify-between">
<span className="text-primary-600">{t.listingForm.lastUpdated}</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">
{t.listingForm.approvalNote}
</p>
</div>
</div>
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex justify-end space-x-3">
<Button type="button" variant="outline" onClick={() => onSuccess?.()}>
{t.listingForm.cancel}
</Button>
<Button
type="button"
variant="secondary"
onClick={handleSubmit}
disabled={isSubmitting || uploadingImages || (!hasSubscription && !initialListing)}
icon={<Save className="h-4 w-4" />}
>
{isSubmitting ? t.listingForm.saving : initialListing ? t.listingForm.update : t.listingForm.create}
</Button>
</CardFooter>
</Card>
);
};
export default ListingForm;

View File

@@ -0,0 +1,229 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Mail, Check, ChevronLeft, ChevronRight } from 'lucide-react';
import Card, { CardContent, CardHeader, CardFooter } from '../common/Card';
import Button from '../common/Button';
import { Message } from '../../types';
import { api } from '../../services/api';
import { normalizeMessage } from '../../lib/normalizers';
const PAGE_SIZE = 20;
const MessageList: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const loadMessages = async (targetPage: number) => {
setLoading(true);
try {
const data = await api.messages.list({ page: targetPage, page_size: PAGE_SIZE });
setMessages((data.messages || data || []).map(normalizeMessage));
setTotal(data.total ?? 0);
} catch (err) {
console.error('Failed to load messages:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadMessages(page);
}, [page]);
const markAsRead = async (messageId: string) => {
try {
await api.messages.markRead(messageId, true);
setMessages(prev =>
prev.map(msg => (msg.id === messageId ? { ...msg, read: true } : msg))
);
} catch (err) {
console.error('Failed to mark message as read:', err);
}
};
const handleSelectMessage = async (message: Message) => {
setSelectedMessage(message);
if (!message.read) {
await markAsRead(message.id);
}
};
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;
if (loading) {
return (
<Card>
<CardContent className="text-center py-12">
<p className="text-primary-500">Loading messages...</p>
</CardContent>
</Card>
);
}
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>
<div className="flex items-center space-x-2">
{!selectedMessage && unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={async () => {
const unread = messages.filter(m => !m.read);
await Promise.allSettled(unread.map(m => api.messages.markRead(m.id, true)));
setMessages(prev => prev.map(m => ({ ...m, read: true })));
}}
icon={<Check className="h-4 w-4" />}
>
Mark all read
</Button>
)}
{selectedMessage && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedMessage(null)}
icon={<ArrowLeft className="h-4 w-4" />}
>
Back to Messages
</Button>
)}
</div>
</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>
<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 flex justify-end">
<Button
variant="secondary"
icon={<ArrowRight className="h-4 w-4" />}
onClick={() => {
window.location.href = `mailto:${selectedMessage.email}`;
}}
>
Reply via Email
</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-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 || total > 0) && (
<CardFooter className="flex items-center justify-between">
<p className="text-sm text-primary-500">
{total} total {unreadCount} unread
</p>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 rounded-md text-primary-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronLeft className="h-5 w-5" />
</button>
<span className="text-sm text-primary-600">
{page} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-1 rounded-md text-primary-600 hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
)}
</CardFooter>
)}
</Card>
);
};
export default MessageList;

View File

@@ -0,0 +1,26 @@
import React, { useState, useEffect } from 'react';
import { ChevronUp } from 'lucide-react';
const BackToTop: React.FC = () => {
const [visible, setVisible] = useState(false);
useEffect(() => {
const onScroll = () => setVisible(window.scrollY > 400);
window.addEventListener('scroll', onScroll, { passive: true });
return () => window.removeEventListener('scroll', onScroll);
}, []);
if (!visible) return null;
return (
<button
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
className="fixed bottom-6 right-6 z-40 p-3 bg-accent-600 hover:bg-accent-700 text-white rounded-full shadow-lg transition-colors"
aria-label="Back to top"
>
<ChevronUp className="h-5 w-5" />
</button>
);
};
export default BackToTop;

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,58 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Link } from 'react-router-dom';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
errorMessage: string;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, errorMessage: '' };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, errorMessage: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught:', error, info);
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="max-w-md w-full text-center">
<p className="text-6xl font-extrabold text-error-500 mb-4">!</p>
<h1 className="text-2xl font-bold text-primary-800 mb-3">Something went wrong</h1>
<p className="text-primary-600 mb-6 text-sm">{this.state.errorMessage || 'An unexpected error occurred.'}</p>
<div className="flex justify-center gap-4">
<button
onClick={() => this.setState({ hasError: false, errorMessage: '' })}
className="px-4 py-2 bg-accent-600 text-white rounded-md hover:bg-accent-700 text-sm font-medium"
>
Try Again
</button>
<Link
to="/"
className="px-4 py-2 border border-gray-300 text-primary-700 rounded-md hover:bg-gray-50 text-sm font-medium"
>
Go Home
</Link>
</div>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

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,314 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Menu, X, ChevronDown, UserCircle, Search, MessageCircle } from 'lucide-react';
import { useAuth } from '../../contexts/AuthContext';
import { useI18n } from '../../contexts/I18nContext';
import { api } from '../../services/api';
import Button from './Button';
const Navbar: React.FC = () => {
const { user, logout, isRole } = useAuth();
const { lang, setLang, t } = useI18n();
const navigate = useNavigate();
const location = useLocation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [unreadCount, setUnreadCount] = useState(0);
// Poll unread message count for agency users
useEffect(() => {
if (!user || user.role !== 'agency') return;
const fetchUnread = () => {
api.messages.unreadCount()
.then((data: any) => setUnreadCount(data.count ?? data.unread_count ?? 0))
.catch(() => {});
};
fetchUnread();
const interval = setInterval(fetchUnread, 60000); // refresh every minute
return () => clearInterval(interval);
}, [user]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
if (searchQuery.trim()) {
navigate(`/listings?search=${encodeURIComponent(searchQuery.trim())}`);
setSearchQuery('');
setIsMenuOpen(false);
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
const isActive = (path: string) => 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';
const LangToggle = () => (
<button
onClick={() => setLang(lang === 'en' ? 'fr' : 'en')}
className="px-2 py-1 text-xs font-semibold rounded border border-primary-300 text-primary-700 hover:bg-primary-50 transition-colors"
title="Switch language"
>
{lang === 'en' ? 'FR' : 'EN'}
</button>
);
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">
Deals24Togo
</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 : ''}`}
>
{t.nav.home}
</Link>
<Link
to="/listings"
className={`${navLinkClasses} ${isActive('/listings') ? navLinkActiveClasses : ''}`}
>
{t.nav.listings}
</Link>
<Link
to="/categories"
className={`${navLinkClasses} ${isActive('/categories') ? navLinkActiveClasses : ''}`}
>
{t.nav.categories}
</Link>
{user && (
<Link
to="/favorites"
className={`${navLinkClasses} ${isActive('/favorites') ? navLinkActiveClasses : ''}`}
>
{t.nav.favorites}
</Link>
)}
{isRole('agency') && (
<Link
to="/agency/dashboard"
className={`${navLinkClasses} ${isActive('/agency/dashboard') ? navLinkActiveClasses : ''} relative inline-flex items-center`}
>
{t.nav.myDashboard}
{unreadCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold rounded-full bg-error-500 text-white leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Link>
)}
{isRole('admin') && (
<Link
to="/admin/dashboard"
className={`${navLinkClasses} ${isActive('/admin/dashboard') ? navLinkActiveClasses : ''}`}
>
{t.nav.adminDashboard}
</Link>
)}
</div>
</div>
</div>
{/* Desktop right section */}
<div className="hidden sm:flex sm:items-center space-x-3">
<form onSubmit={handleSearch} className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.nav.searchPlaceholder}
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-56"
/>
<button type="submit" className="absolute left-3 top-2.5 p-0 bg-transparent border-0">
<Search className="h-5 w-5 text-gray-400" />
</button>
</form>
<LangToggle />
{user ? (
<div className="relative">
<button
onClick={() => setIsProfileOpen(p => !p)}
className="flex items-center bg-gray-100 rounded-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500"
>
<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>
{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 z-10 animate-fade-in">
<div className="px-4 py-2 text-xs text-gray-500">
{user.email}
</div>
<div className="border-t border-gray-200" />
<Link
to="/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setIsProfileOpen(false)}
>
{t.nav.myProfile}
</Link>
{isRole('agency') && (
<Link
to="/agency/dashboard"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setIsProfileOpen(false)}
>
{t.nav.agencyProfile}
</Link>
)}
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
{t.nav.signOut}
</button>
</div>
)}
</div>
) : (
<div className="flex items-center space-x-2">
<Link to="/login">
<Button variant="outline" size="sm">{t.nav.signIn}</Button>
</Link>
<Link to="/register">
<Button size="sm">{t.nav.register}</Button>
</Link>
</div>
)}
</div>
{/* Mobile menu button */}
<div className="flex items-center sm:hidden space-x-2">
<LangToggle />
<button
onClick={() => setIsMenuOpen(o => !o)}
className="p-2 rounded-md text-primary-600 hover:text-primary-800 focus:outline-none"
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="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-4 pt-3 pb-2">
<form onSubmit={handleSearch} className="relative">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t.nav.searchPlaceholder}
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"
/>
<button type="submit" className="absolute left-3 top-2.5 p-0 bg-transparent border-0">
<Search className="h-5 w-5 text-gray-400" />
</button>
</form>
</div>
<div className="px-2 pt-1 pb-3 space-y-1">
{[
{ to: '/', label: t.nav.home },
{ to: '/listings', label: t.nav.listings },
{ to: '/categories', label: t.nav.categories },
].map(({ to, label }) => (
<Link
key={to}
to={to}
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
onClick={() => setIsMenuOpen(false)}
>
{label}
</Link>
))}
{isRole('agency') && (
<Link
to="/agency/dashboard"
className="flex items-center px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
onClick={() => setIsMenuOpen(false)}
>
{t.nav.myDashboard}
{unreadCount > 0 && (
<span className="ml-2 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold rounded-full bg-error-500 text-white leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</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)}
>
{t.nav.adminDashboard}
</Link>
)}
</div>
<div className="pt-4 pb-3 border-t border-gray-200">
{user ? (
<>
<div className="flex items-center px-4">
<UserCircle className="h-10 w-10 text-primary-600" />
<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">
<Link
to="/profile"
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
onClick={() => setIsMenuOpen(false)}
>
{t.nav.myProfile}
</Link>
<Link
to="/favorites"
className="block px-3 py-2 rounded-md text-base font-medium text-primary-700 hover:bg-gray-100"
onClick={() => setIsMenuOpen(false)}
>
{t.nav.favorites}
</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"
>
{t.nav.signOut}
</button>
</div>
</>
) : (
<div className="px-4 flex flex-col space-y-2">
<Link to="/login" onClick={() => setIsMenuOpen(false)}>
<Button variant="outline" fullWidth>{t.nav.signIn}</Button>
</Link>
<Link to="/register" onClick={() => setIsMenuOpen(false)}>
<Button fullWidth>{t.nav.register}</Button>
</Link>
</div>
)}
</div>
</div>
)}
</nav>
);
};
export default Navbar;

View File

@@ -0,0 +1,33 @@
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',
sold: 'bg-primary-100 text-primary-800 border border-primary-300',
}[status] ?? 'bg-gray-50 text-gray-700 border border-gray-300';
const statusText = {
pending: 'Pending Review',
approved: 'Approved',
rejected: 'Rejected',
sold: 'Sold',
}[status] ?? status;
return (
<span className={`${baseClasses} ${statusClasses} ${className}`}>
{statusText}
</span>
);
};
export default StatusBadge;

View File

@@ -0,0 +1,195 @@
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 { api } from '../../services/api';
import { useI18n } from '../../contexts/I18nContext';
interface ContactFormProps {
listing: Listing;
}
const ContactForm: React.FC<ContactFormProps> = ({ listing }) => {
const { t } = useI18n();
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 [submitError, setSubmitError] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
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 = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
setSubmitError('');
try {
await api.messages.send({
listing_id: listing.id,
name: formData.name,
email: formData.email,
phone: formData.phone || undefined,
message: formData.message,
});
setIsSubmitted(true);
setTimeout(() => {
setIsSubmitted(false);
setFormData({ name: '', email: '', phone: '', message: '' });
}, 5000);
} catch (err: any) {
setSubmitError(err.message || 'Failed to send message. Please try again.');
} finally {
setIsSubmitting(false);
}
};
return (
<Card className="sticky top-4 w-full">
<CardHeader className="bg-primary-50">
<h3 className="text-lg font-semibold text-primary-800">{t.contact.title}</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">{t.contact.successTitle}</h4>
<p className="text-primary-600">{t.contact.successMessage}</p>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="space-y-4">
{submitError && (
<div className="p-3 bg-error-50 border border-error-200 rounded-md text-error-700 text-sm">
{submitError}
</div>
)}
<div>
<label htmlFor="name" className="block text-sm font-medium text-primary-700 mb-1">
{t.contact.yourName} <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">
{t.contact.emailAddress} <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">
{t.contact.phoneNumber}
</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">
{t.contact.message} <span className="text-error-500">*</span>
</label>
<textarea
id="message"
name="message"
rows={4}
value={formData.message}
onChange={handleChange}
placeholder={t.contact.messagePlaceholder}
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'
}`}
/>
{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>{t.contact.sending}</span>
) : (
<>
<span>{t.contact.send}</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">
{t.contact.terms}{' '}
<a href="/about" className="text-accent-600 hover:underline">{t.contact.termsOfService}</a>{' '}
{t.contact.and}{' '}
<a href="/about" className="text-accent-600 hover:underline">{t.contact.privacyPolicy}</a>.
</p>
</div>
</form>
)}
</CardContent>
</Card>
);
};
export default ContactForm;

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { MapPin, Eye, Heart } from 'lucide-react';
import Card from '../common/Card';
import StatusBadge from '../common/StatusBadge';
import { Listing } from '../../types';
import { useAuth } from '../../contexts/AuthContext';
import { api } from '../../services/api';
interface ListingCardProps {
listing: Listing;
showStatus?: boolean;
className?: string;
initialFav?: boolean;
}
const ListingCard: React.FC<ListingCardProps> = ({
listing,
showStatus = false,
className = '',
initialFav,
}) => {
const { id, title, price, images, location, status, categoryName, listingType } = listing;
const { user } = useAuth();
const [isFav, setIsFav] = useState(initialFav ?? false);
const [favLoading, setFavLoading] = useState(false);
const formatter = new Intl.NumberFormat('fr-TG', {
style: 'currency',
currency: 'XOF',
minimumFractionDigits: 0,
});
// Only do individual check if initialFav was not provided
useEffect(() => {
if (!user || initialFav !== undefined) return;
api.favorites.check(id)
.then((data: any) => setIsFav(data.is_favorite ?? data.isFavorite ?? false))
.catch(() => {});
}, [id, user, initialFav]);
// Sync initialFav when it changes (e.g. parent re-fetched)
useEffect(() => {
if (initialFav !== undefined) setIsFav(initialFav);
}, [initialFav]);
const toggleFav = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!user || favLoading) return;
// Optimistic update — flip immediately, revert on error
const previous = isFav;
setIsFav(!previous);
setFavLoading(true);
try {
if (previous) {
await api.favorites.remove(id);
} else {
await api.favorites.add(id);
}
} catch {
setIsFav(previous);
} finally {
setFavLoading(false);
}
};
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}
loading="lazy"
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>
)}
{/* Favorite button */}
{user && (
<button
onClick={toggleFav}
disabled={favLoading}
className={`absolute top-2 left-2 bg-white rounded-full p-1.5 shadow-sm transition-colors ${
isFav ? 'text-error-500' : 'text-gray-400 hover:text-error-400'
}`}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
<Heart className={`h-4 w-4 ${isFav ? 'fill-current' : ''}`} />
</button>
)}
<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">
{categoryName || '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)}
{listingType === 'rent' && (
<span className="text-sm font-normal text-primary-500">/mois</span>
)}
</span>
{listingType && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
listingType === 'rent'
? 'bg-blue-100 text-blue-700'
: 'bg-success-50 text-success-700'
}`}>
{listingType === 'rent' ? 'À louer' : 'À vendre'}
</span>
)}
</div>
</div>
</Link>
</Card>
);
};
export default ListingCard;

View File

@@ -0,0 +1,116 @@
import React from 'react';
import { Printer } from 'lucide-react';
import { Payment } from '../../types';
import { useI18n } from '../../contexts/I18nContext';
import Button from '../common/Button';
interface ReceiptProps {
payment: Payment;
}
const Receipt: React.FC<ReceiptProps> = ({ payment }) => {
const { t } = useI18n();
const formatter = new Intl.NumberFormat('fr-TG', {
style: 'currency',
currency: 'XOF',
minimumFractionDigits: 0,
});
const lineItem =
payment.type === 'subscription'
? payment.plan_label || 'Abonnement'
: payment.listing_title || 'Achat annonce';
const paidDate = payment.paid_at
? new Date(payment.paid_at).toLocaleString('fr-TG')
: new Date(payment.created_at).toLocaleString('fr-TG');
return (
<>
{/* Print-only styles */}
<style>{`
@media print {
body > *:not(#receipt-root) { display: none !important; }
#receipt-root { display: block !important; }
.no-print { display: none !important; }
.receipt-card {
box-shadow: none !important;
border: 1px solid #ccc !important;
}
}
`}</style>
<div id="receipt-root" className="receipt-card bg-white border border-gray-200 rounded-lg p-6 max-w-md mx-auto">
{/* Header */}
<div className="text-center mb-6 border-b border-gray-200 pb-4">
<p className="text-2xl font-bold text-primary-800">Deals24Togo</p>
<p className="text-sm text-primary-500 mt-1">{t.payment.receiptTitle}</p>
</div>
{/* Status */}
<div className="text-center mb-4">
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-success-50 text-success-700 border border-success-300">
{t.payment.success}
</span>
</div>
{/* Details */}
<div className="space-y-3 text-sm mb-6">
<div className="flex justify-between">
<span className="text-primary-500">{t.payment.transactionId}</span>
<span className="font-mono text-primary-800 text-xs">{payment.transaction_id}</span>
</div>
<div className="flex justify-between">
<span className="text-primary-500">{t.payment.date}</span>
<span className="text-primary-800">{paidDate}</span>
</div>
{payment.payer_name && (
<div className="flex justify-between">
<span className="text-primary-500">{t.payment.paidBy}</span>
<span className="text-primary-800">{payment.payer_name}</span>
</div>
)}
{payment.payment_method && (
<div className="flex justify-between">
<span className="text-primary-500">{t.payment.method}</span>
<span className="text-primary-800">{payment.payment_method}</span>
</div>
)}
</div>
{/* Line item */}
<div className="border-t border-b border-gray-200 py-3 my-4">
<div className="flex justify-between">
<span className="text-primary-700">{lineItem}</span>
<span className="font-medium text-primary-800">{formatter.format(payment.amount)}</span>
</div>
</div>
{/* Total */}
<div className="flex justify-between font-bold text-lg mt-2">
<span className="text-primary-800">{t.payment.total}</span>
<span className="text-accent-700">{formatter.format(payment.amount)}</span>
</div>
{/* Footer */}
<div className="mt-6 text-center text-xs text-primary-400">
Deals24Togo · deals24togo.com
</div>
</div>
{/* Print button — hidden when printing */}
<div className="no-print flex justify-center mt-4">
<Button
variant="outline"
onClick={() => window.print()}
icon={<Printer className="h-4 w-4" />}
>
{t.payment.printReceipt}
</Button>
</div>
</>
);
};
export default Receipt;

View File

@@ -0,0 +1,117 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { User, Agency, UserRole } from '../types';
import { api } from '../services/api';
import { normalizeUser, normalizeAgency } from '../lib/normalizers';
interface RegisterData {
email: string;
password: string;
name: string;
role?: string;
}
interface AuthContextType {
user: User | null;
agency: Agency | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
register: (data: RegisterData) => Promise<void>;
logout: () => void;
isRole: (role: UserRole) => boolean;
refreshUser: () => Promise<void>;
refreshAgency: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [agency, setAgency] = useState<Agency | null>(null);
const [loading, setLoading] = useState(true);
// Restore session on mount
useEffect(() => {
const restore = async () => {
const token = localStorage.getItem('access_token');
if (!token) {
setLoading(false);
return;
}
try {
const data = await api.auth.me();
const normalizedUser = normalizeUser(data);
setUser(normalizedUser);
if (normalizedUser.role === 'agency') {
await fetchAgency();
}
} catch {
// Token invalid — clear storage
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
} finally {
setLoading(false);
}
};
restore();
}, []);
const fetchAgency = async () => {
try {
const data = await api.agencies.me();
setAgency(normalizeAgency(data));
} catch {
setAgency(null);
}
};
const login = async (email: string, password: string) => {
setLoading(true);
try {
const data = await api.auth.login(email, password);
const normalizedUser = normalizeUser(data.user);
setUser(normalizedUser);
if (normalizedUser.role === 'agency') {
await fetchAgency();
}
} finally {
setLoading(false);
}
};
const register = async (data: RegisterData) => {
// Registration no longer returns tokens — Supabase requires email verification first
await api.auth.register(data);
};
const logout = () => {
api.auth.logout();
setUser(null);
setAgency(null);
};
const refreshUser = async () => {
try {
const data = await api.users.me();
setUser(normalizeUser(data));
} catch {}
};
const refreshAgency = fetchAgency;
const isRole = (role: UserRole) => user?.role === role;
return (
<AuthContext.Provider value={{ user, agency, loading, login, register, logout, isRole, refreshUser, refreshAgency }}>
{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,36 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import translations, { Lang, Translations } from '../i18n/translations';
interface I18nContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: Translations;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
const STORAGE_KEY = 'deals24_lang';
export const I18nProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [lang, setLangState] = useState<Lang>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return (stored === 'fr' || stored === 'en') ? stored : 'fr';
});
const setLang = (newLang: Lang) => {
localStorage.setItem(STORAGE_KEY, newLang);
setLangState(newLang);
};
return (
<I18nContext.Provider value={{ lang, setLang, t: translations[lang] }}>
{children}
</I18nContext.Provider>
);
};
export const useI18n = () => {
const ctx = useContext(I18nContext);
if (!ctx) throw new Error('useI18n must be used within I18nProvider');
return ctx;
};

View File

@@ -0,0 +1,122 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { Listing, ListingStatus } from '../types';
import { AuthContext } from './AuthContext';
import { api } from '../services/api';
import { normalizeListing } from '../lib/normalizers';
interface ListingContextType {
listings: Listing[];
loading: boolean;
refreshListings: () => Promise<void>;
addListing: (data: Record<string, any>) => Promise<void>;
updateListingStatus: (id: string, status: ListingStatus, rejectionReason?: string) => Promise<void>;
updateListing: (id: string, data: Record<string, any>) => 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);
export const ListingProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const authCtx = useContext(AuthContext);
const user = authCtx?.user ?? null;
const authLoading = authCtx?.loading ?? true;
const [listings, setListings] = useState<Listing[]>([]);
const [loading, setLoading] = useState(false);
const fetchListings = async () => {
if (!user) {
setListings([]);
return;
}
setLoading(true);
try {
if (user.role === 'admin') {
const data = await api.listings.adminAll({ page_size: 100 });
setListings((data.listings || []).map(normalizeListing));
} else if (user.role === 'agency') {
const data = await api.listings.mine({ page_size: 100 });
setListings((data.listings || []).map(normalizeListing));
} else {
setListings([]);
}
} catch (err) {
console.error('Failed to fetch listings:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (authLoading) return;
fetchListings();
}, [user?.id, authLoading]);
const refreshListings = () => fetchListings();
const addListing = async (data: Record<string, any>) => {
const result = await api.listings.create(data);
setListings(prev => [normalizeListing(result), ...prev]);
};
const updateListingStatus = async (id: string, status: ListingStatus, rejectionReason?: string) => {
await api.listings.updateStatus(id, status, rejectionReason);
setListings(prev =>
prev.map(l =>
l.id === id ? { ...l, status, rejectionReason: rejectionReason ?? l.rejectionReason } : l
)
);
};
const updateListing = async (id: string, data: Record<string, any>) => {
const result = await api.listings.update(id, data);
setListings(prev => prev.map(l => (l.id === id ? normalizeListing(result) : l)));
};
const deleteListing = async (id: string) => {
await api.listings.delete(id);
setListings(prev => prev.filter(l => l.id !== id));
};
const getListingsByStatus = (status: ListingStatus) =>
listings.filter(l => l.status === status);
const getListingsByAgency = (agencyId: string) =>
listings.filter(l => l.agencyId === agencyId);
const getListingsByCategory = (categoryId: string) =>
listings.filter(l => l.categoryId === categoryId);
const getListingById = (id: string) =>
listings.find(l => l.id === id);
return (
<ListingContext.Provider value={{
listings,
loading,
refreshListings,
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;
};

View File

@@ -0,0 +1,56 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
type ToastType = 'success' | 'error' | 'info';
interface Toast {
id: number;
message: string;
type: ToastType;
}
interface ToastContextType {
showToast: (message: string, type?: ToastType) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: ToastType = 'info') => {
const id = Date.now();
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 4000);
}, []);
const typeClasses: Record<ToastType, string> = {
success: 'bg-success-600 text-white',
error: 'bg-error-600 text-white',
info: 'bg-primary-700 text-white',
};
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* Toast container */}
<div className="fixed bottom-4 right-4 z-[200] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={`px-4 py-3 rounded-lg shadow-lg text-sm font-medium max-w-xs pointer-events-auto animate-fade-in ${typeClasses[toast.type]}`}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used within ToastProvider');
return ctx;
};

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

12
src/hooks/usePageTitle.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useEffect } from 'react';
const SITE_NAME = 'Deals24Togo';
export function usePageTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title} | ${SITE_NAME}` : SITE_NAME;
return () => {
document.title = SITE_NAME;
};
}, [title]);
}

2
src/i18n.ts Normal file
View File

@@ -0,0 +1,2 @@
export { useI18n, I18nProvider } from './contexts/I18nContext';
export type { Lang } from './i18n/translations';

581
src/i18n/translations.ts Normal file
View File

@@ -0,0 +1,581 @@
export type Lang = 'en' | 'fr';
const translations = {
en: {
nav: {
home: 'Home',
listings: 'Listings',
categories: 'Categories',
myDashboard: 'My Dashboard',
adminDashboard: 'Admin Dashboard',
signIn: 'Sign in',
register: 'Register',
signOut: 'Sign out',
myProfile: 'My Profile',
favorites: 'Favorites',
agencyProfile: 'Agency Profile',
searchPlaceholder: 'Search listings...',
},
home: {
heroTitle: 'Discover Professional Listings from Verified Agencies',
heroSubtitle: 'Browse quality listings across multiple categories, all verified by our admin team.',
searchPlaceholder: 'What are you looking for?',
allCategories: 'All Categories',
search: 'Search',
browseAll: 'Browse All Listings',
exploreCategories: 'Explore Categories',
browseByCategory: 'Browse by Category',
browseByCategorySubtitle: "Explore our wide range of categories to find exactly what you're looking for",
viewAllCategories: 'View All Categories',
featuredListings: 'Featured Listings',
featuredSubtitle: 'Hand-picked quality listings from our verified agencies',
viewAllListings: 'View All Listings',
howItWorks: 'How It Works',
howItWorksSubtitle: 'Our platform ensures quality listings through a simple but effective process',
verifiedAgencies: 'Verified Agencies',
verifiedAgenciesDesc: 'We only allow verified professional agencies to post listings on our platform, ensuring trustworthy offers.',
adminApproval: 'Admin Approval',
adminApprovalDesc: 'Every listing is reviewed by our admin team before being published to ensure quality and prevent fraud.',
directContact: 'Direct Contact',
directContactDesc: 'Easily contact agencies about listings that interest you through our built-in messaging system.',
ctaTitle: 'Are you an agency looking to post listings?',
ctaSubtitle: 'Register your agency today and start showcasing your listings to our audience.',
registerAsAgency: 'Register as Agency',
},
auth: {
signIn: 'Sign In',
signInSubtitle: 'Enter your email and password to access your account',
email: 'Email address',
password: 'Password',
forgotPassword: 'Forgot password?',
noAccount: "Don't have an account?",
register: 'Register',
orUseDemo: 'Or use a demo account',
},
listings: {
browse: 'Browse Listings',
searchPlaceholder: 'Search listings...',
noResults: 'No listings found',
clearFilters: 'Clear Filters',
category: 'Category',
uncategorized: 'Uncategorized',
listed: 'Listed',
allCategories: 'All Categories',
minPrice: 'Min price',
maxPrice: 'Max price',
apply: 'Apply',
newest: 'Newest',
oldest: 'Oldest',
priceLow: 'Price: Low to High',
priceHigh: 'Price: High to Low',
popular: 'Most Popular',
listingFound: 'listing found',
listingsFound: 'listings found',
categoryLabel: 'Category:',
},
categories: {
title: 'Browse by Category',
subtitle: "Explore our wide range of categories to find exactly what you're looking for",
none: 'No categories available yet.',
errorLoading: 'Failed to load categories.',
retry: 'Retry',
},
favorites: {
title: 'My Favorites',
saved: 'saved listing',
savedPlural: 'saved listings',
removeFromFavorites: 'Remove from favorites',
none: 'No favorites yet',
noneDesc: 'Browse listings and click the heart icon to save your favorites here.',
browseListings: 'Browse Listings',
},
profile: {
title: 'My Account',
editProfile: 'Edit Profile',
fullName: 'Full Name',
email: 'Email',
phone: 'Phone',
saving: 'Saving...',
saveChanges: 'Save Changes',
changePassword: 'Change Password',
currentPassword: 'Current Password',
newPassword: 'New Password',
confirmPassword: 'Confirm New Password',
changing: 'Changing...',
myFavorites: 'My Favorites',
signOut: 'Sign Out',
profileUpdated: 'Profile updated successfully!',
passwordChanged: 'Password changed successfully!',
avatarUpdated: 'Avatar updated!',
passwordsMismatch: 'New passwords do not match',
passwordTooShort: 'Password must be at least 8 characters',
profileTab: 'Profile',
passwordTab: 'Password',
},
listing: {
notFound: 'Listing Not Found',
notFoundDesc: "The listing you're looking for doesn't exist or has been removed.",
browseAll: 'Browse All Listings',
backToListings: 'Back to listings',
share: 'Share',
linkCopied: 'Link copied to clipboard!',
couldNotCopy: 'Could not copy link',
perMonth: '/month',
negotiable: 'Negotiable',
view: 'view',
views: 'views',
rejectionReason: 'Rejection reason:',
description: 'Description',
listedBy: 'Listed By',
verifiedAgency: 'Verified Agency',
viewAllListings: 'View all listings',
website: 'Website',
whatsapp: 'WhatsApp',
forSale: 'For Sale',
forRent: 'For Rent',
postedOn: 'Posted on',
},
agency: {
notFound: 'Agency Not Found',
notFoundDesc: "This agency doesn't exist or has been removed.",
browseListings: 'Browse Listings',
verified: 'Verified',
activeListings: 'Active Listings',
listingsBy: 'Listings by',
failedToLoad: 'Failed to load listings.',
retry: 'Retry',
noListings: 'This agency has no active listings right now.',
},
listingForm: {
editTitle: 'Edit Listing',
createTitle: 'Create New Listing',
titleLabel: 'Title',
descriptionLabel: 'Description',
price: 'Price (FCFA)',
category: 'Category',
selectCategory: 'Select a category',
location: 'Location',
type: 'Type',
forSale: 'For Sale',
forRent: 'For Rent',
condition: 'Condition',
notSpecified: 'Not specified',
conditionNew: 'New',
conditionUsed: 'Used',
conditionRefurbished: 'Refurbished',
negotiable: 'Price is negotiable',
images: 'Images',
upload: 'Upload Images',
uploading: 'Uploading...',
cancel: 'Cancel',
imageHint: 'JPEG, PNG, WebP or GIF — max 10 MB each',
noImages: 'No images added yet. Click "Upload Images" above.',
listingInfo: 'Listing Information',
statusLabel: 'Status:',
pendingSubmit: 'Will be submitted as Pending',
agencyLabel: 'Agency:',
created: 'Created:',
lastUpdated: 'Last Updated:',
approvalNote: 'All listings require admin approval before they appear online.',
update: 'Update Listing',
create: 'Create Listing',
saving: 'Saving...',
titleRequired: 'Title is required',
descriptionRequired: 'Description is required',
priceRequired: 'Price must be greater than 0',
categoryRequired: 'Category is required',
locationRequired: 'Location is required',
imageRequired: 'At least one image is required',
titlePlaceholder: 'Enter a descriptive title',
descriptionPlaceholder: 'Provide a detailed description of your listing',
locationPlaceholder: 'City, neighborhood or Online',
},
forgotPassword: {
title: 'Forgot Password?',
subtitle: "Enter your email and we'll send you a link to reset your password.",
emailLabel: 'Email Address',
submit: 'Send Reset Link',
submitting: 'Sending...',
checkEmail: 'Check your email',
checkEmailDesc: "we've sent a password reset link. Please check your inbox and spam folder.",
backToSignIn: 'Back to Sign In',
didntReceive: "Didn't receive it?",
resend: 'Resend email',
resending: 'Sending...',
emailRequired: 'Please enter your email address',
error: 'Something went wrong. Please try again.',
},
contact: {
title: 'Contact About This Listing',
yourName: 'Your Name',
emailAddress: 'Email Address',
phoneNumber: 'Phone Number (optional)',
message: 'Message',
messagePlaceholder: "I'm interested in this listing...",
send: 'Send Message',
sending: 'Sending...',
successTitle: 'Message Sent!',
successMessage: 'Thank you for your inquiry. The agency will get back to you soon.',
terms: 'By sending a message, you agree to our',
termsOfService: 'Terms of Service',
and: 'and',
privacyPolicy: 'Privacy Policy',
},
status: {
pending: 'Pending',
approved: 'Approved',
rejected: 'Rejected',
},
common: {
loading: 'Loading...',
backToListings: 'Back to listings',
save: 'Save',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
view: 'View',
verify: 'Verify',
revoke: 'Revoke',
approve: 'Approve',
reject: 'Reject',
},
subscription: {
title: 'Subscription Plans',
monthly: 'Monthly Plan',
yearly: 'Yearly Plan',
monthlyDesc: 'Post listings for 1 month',
yearlyDesc: 'Post listings for 12 months — save 2 months!',
active: 'Active',
expired: 'Expired',
activePlan: 'Active Plan',
activeUntil: 'Active until',
renewBefore: 'Renew before',
required: 'A subscription is required to post listings.',
noSubscription: 'No active subscription',
noSubscriptionDesc: 'Subscribe to start posting listings.',
subscribe: 'Subscribe',
currentPlan: 'Current Plan',
choosePlan: 'Choose a Plan',
saveMonths: 'Save 2 months',
perMonth: '/ month',
perYear: '/ year',
loadingStatus: 'Loading subscription status...',
subscribeTab: 'Subscription',
},
payment: {
processing: 'Processing payment...',
success: 'Payment successful!',
failed: 'Payment failed',
cancelled: 'Payment cancelled',
pending: 'Payment pending',
printReceipt: 'Print Receipt',
transactionId: 'Transaction ID',
date: 'Date',
method: 'Payment Method',
total: 'Total',
receiptTitle: 'Payment Receipt',
paidBy: 'Paid by',
description: 'Description',
backToHome: 'Back to Home',
tryAgain: 'Try Again',
checkingStatus: 'Checking payment status...',
},
purchase: {
buyNow: 'Buy Now',
buyFor: 'Buy for',
confirmPurchase: 'Confirm Purchase',
sold: 'Sold',
alreadySold: 'This listing has been sold.',
},
},
fr: {
nav: {
home: 'Accueil',
listings: 'Annonces',
categories: 'Catégories',
myDashboard: 'Mon Tableau de Bord',
adminDashboard: 'Tableau Admin',
signIn: 'Connexion',
register: "S'inscrire",
signOut: 'Se déconnecter',
myProfile: 'Mon Profil',
favorites: 'Favoris',
agencyProfile: 'Profil Agence',
searchPlaceholder: 'Rechercher des annonces...',
},
home: {
heroTitle: "Découvrez des Annonces Professionnelles d'Agences Vérifiées",
heroSubtitle: 'Parcourez des annonces de qualité dans plusieurs catégories, toutes vérifiées par notre équipe.',
searchPlaceholder: 'Que cherchez-vous?',
allCategories: 'Toutes les Catégories',
search: 'Rechercher',
browseAll: 'Toutes les Annonces',
exploreCategories: 'Explorer les Catégories',
browseByCategory: 'Parcourir par Catégorie',
browseByCategorySubtitle: 'Explorez notre large gamme de catégories pour trouver exactement ce que vous cherchez',
viewAllCategories: 'Voir Toutes les Catégories',
featuredListings: 'Annonces en Vedette',
featuredSubtitle: 'Annonces de qualité sélectionnées par nos agences vérifiées',
viewAllListings: 'Voir Toutes les Annonces',
howItWorks: 'Comment Ça Marche',
howItWorksSubtitle: 'Notre plateforme garantit des annonces de qualité grâce à un processus simple et efficace',
verifiedAgencies: 'Agences Vérifiées',
verifiedAgenciesDesc: "Nous n'autorisons que les agences professionnelles vérifiées à publier des annonces sur notre plateforme.",
adminApproval: 'Approbation Admin',
adminApprovalDesc: "Chaque annonce est examinée par notre équipe avant d'être publiée pour garantir la qualité et prévenir les fraudes.",
directContact: 'Contact Direct',
directContactDesc: "Contactez facilement les agences pour les annonces qui vous intéressent via notre système de messagerie intégré.",
ctaTitle: 'Vous êtes une agence et souhaitez publier des annonces?',
ctaSubtitle: "Inscrivez votre agence aujourd'hui et commencez à présenter vos annonces à notre audience.",
registerAsAgency: "S'inscrire comme Agence",
},
auth: {
signIn: 'Connexion',
signInSubtitle: 'Entrez votre email et mot de passe pour accéder à votre compte',
email: 'Adresse e-mail',
password: 'Mot de passe',
forgotPassword: 'Mot de passe oublié?',
noAccount: 'Pas encore de compte?',
register: "S'inscrire",
orUseDemo: 'Ou utiliser un compte démo',
},
listings: {
browse: 'Parcourir les Annonces',
searchPlaceholder: 'Rechercher des annonces...',
noResults: 'Aucune annonce trouvée',
clearFilters: 'Effacer les Filtres',
category: 'Catégorie',
uncategorized: 'Non catégorisé',
listed: 'Publié',
allCategories: 'Toutes les Catégories',
minPrice: 'Prix min',
maxPrice: 'Prix max',
apply: 'Appliquer',
newest: 'Plus récent',
oldest: 'Plus ancien',
priceLow: 'Prix: Croissant',
priceHigh: 'Prix: Décroissant',
popular: 'Plus Populaire',
listingFound: 'annonce trouvée',
listingsFound: 'annonces trouvées',
categoryLabel: 'Catégorie:',
},
categories: {
title: 'Parcourir par Catégorie',
subtitle: 'Explorez notre large gamme de catégories pour trouver exactement ce que vous cherchez',
none: 'Aucune catégorie disponible pour le moment.',
errorLoading: 'Impossible de charger les catégories.',
retry: 'Réessayer',
},
favorites: {
title: 'Mes Favoris',
saved: 'annonce sauvegardée',
savedPlural: 'annonces sauvegardées',
removeFromFavorites: 'Retirer des favoris',
none: 'Aucun favori pour le moment',
noneDesc: 'Parcourez les annonces et cliquez sur le cœur pour sauvegarder vos favoris ici.',
browseListings: 'Voir les Annonces',
},
profile: {
title: 'Mon Compte',
editProfile: 'Modifier le Profil',
fullName: 'Nom Complet',
email: 'E-mail',
phone: 'Téléphone',
saving: 'Enregistrement...',
saveChanges: 'Enregistrer les Modifications',
changePassword: 'Changer le Mot de Passe',
currentPassword: 'Mot de Passe Actuel',
newPassword: 'Nouveau Mot de Passe',
confirmPassword: 'Confirmer le Nouveau Mot de Passe',
changing: 'Modification...',
myFavorites: 'Mes Favoris',
signOut: 'Se Déconnecter',
profileUpdated: 'Profil mis à jour avec succès!',
passwordChanged: 'Mot de passe modifié avec succès!',
avatarUpdated: 'Avatar mis à jour!',
passwordsMismatch: 'Les nouveaux mots de passe ne correspondent pas',
passwordTooShort: 'Le mot de passe doit contenir au moins 8 caractères',
profileTab: 'Profil',
passwordTab: 'Mot de Passe',
},
listing: {
notFound: 'Annonce Introuvable',
notFoundDesc: "L'annonce que vous recherchez n'existe pas ou a été supprimée.",
browseAll: 'Voir Toutes les Annonces',
backToListings: 'Retour aux annonces',
share: 'Partager',
linkCopied: 'Lien copié dans le presse-papiers!',
couldNotCopy: 'Impossible de copier le lien',
perMonth: '/mois',
negotiable: 'Négociable',
view: 'vue',
views: 'vues',
rejectionReason: 'Motif de rejet:',
description: 'Description',
listedBy: 'Publié Par',
verifiedAgency: 'Agence Vérifiée',
viewAllListings: 'Voir toutes les annonces',
website: 'Site Web',
whatsapp: 'WhatsApp',
forSale: 'À Vendre',
forRent: 'À Louer',
postedOn: 'Publié le',
},
agency: {
notFound: 'Agence Introuvable',
notFoundDesc: "Cette agence n'existe pas ou a été supprimée.",
browseListings: 'Voir les Annonces',
verified: 'Vérifiée',
activeListings: 'Annonces Actives',
listingsBy: 'Annonces de',
failedToLoad: 'Impossible de charger les annonces.',
retry: 'Réessayer',
noListings: "Cette agence n'a pas d'annonces actives pour le moment.",
},
listingForm: {
editTitle: "Modifier l'Annonce",
createTitle: 'Créer une Nouvelle Annonce',
titleLabel: 'Titre',
descriptionLabel: 'Description',
price: 'Prix (FCFA)',
category: 'Catégorie',
selectCategory: 'Sélectionner une catégorie',
location: 'Localisation',
type: 'Type',
forSale: 'À Vendre',
forRent: 'À Louer',
condition: 'État',
notSpecified: 'Non spécifié',
conditionNew: 'Neuf',
conditionUsed: 'Occasion',
conditionRefurbished: 'Reconditionné',
negotiable: 'Prix négociable',
images: 'Images',
upload: 'Télécharger des Images',
uploading: 'Téléchargement...',
cancel: 'Annuler',
imageHint: 'JPEG, PNG, WebP ou GIF — 10 Mo max chacun',
noImages: 'Aucune image ajoutée. Cliquez sur "Télécharger des Images" ci-dessus.',
listingInfo: "Informations sur l'Annonce",
statusLabel: 'Statut:',
pendingSubmit: 'Sera soumis comme En attente',
agencyLabel: 'Agence:',
created: 'Créé:',
lastUpdated: 'Dernière mise à jour:',
approvalNote: "Toutes les annonces nécessitent l'approbation de l'administrateur avant d'apparaître en ligne.",
update: "Mettre à Jour l'Annonce",
create: "Créer l'Annonce",
saving: 'Enregistrement...',
titleRequired: 'Le titre est requis',
descriptionRequired: 'La description est requise',
priceRequired: 'Le prix doit être supérieur à 0',
categoryRequired: 'La catégorie est requise',
locationRequired: 'La localisation est requise',
imageRequired: 'Au moins une image est requise',
titlePlaceholder: 'Entrez un titre descriptif',
descriptionPlaceholder: 'Fournissez une description détaillée de votre annonce',
locationPlaceholder: 'Ville, quartier ou En ligne',
},
forgotPassword: {
title: 'Mot de Passe Oublié?',
subtitle: 'Entrez votre e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.',
emailLabel: 'Adresse E-mail',
submit: 'Envoyer le Lien',
submitting: 'Envoi...',
checkEmail: 'Vérifiez votre e-mail',
checkEmailDesc: "nous avons envoyé un lien de réinitialisation. Vérifiez votre boîte de réception et vos spams.",
backToSignIn: 'Retour à la Connexion',
didntReceive: 'Pas reçu?',
resend: "Renvoyer l'e-mail",
resending: 'Envoi...',
emailRequired: 'Veuillez saisir votre adresse e-mail',
error: 'Une erreur est survenue. Veuillez réessayer.',
},
contact: {
title: 'Contacter pour cette Annonce',
yourName: 'Votre Nom',
emailAddress: 'Adresse E-mail',
phoneNumber: 'Numéro de Téléphone (optionnel)',
message: 'Message',
messagePlaceholder: 'Je suis intéressé(e) par cette annonce...',
send: 'Envoyer le Message',
sending: 'Envoi en cours...',
successTitle: 'Message Envoyé!',
successMessage: "Merci pour votre demande. L'agence vous contactera bientôt.",
terms: 'En envoyant un message, vous acceptez nos',
termsOfService: "Conditions d'Utilisation",
and: 'et',
privacyPolicy: 'Politique de Confidentialité',
},
status: {
pending: 'En attente',
approved: 'Approuvé',
rejected: 'Rejeté',
},
common: {
loading: 'Chargement...',
backToListings: 'Retour aux annonces',
save: 'Enregistrer',
cancel: 'Annuler',
delete: 'Supprimer',
edit: 'Modifier',
view: 'Voir',
verify: 'Vérifier',
revoke: 'Révoquer',
approve: 'Approuver',
reject: 'Rejeter',
},
subscription: {
title: "Plans d'Abonnement",
monthly: 'Plan Mensuel',
yearly: 'Plan Annuel',
monthlyDesc: 'Publiez des annonces pendant 1 mois',
yearlyDesc: 'Publiez des annonces pendant 12 mois — économisez 2 mois!',
active: 'Actif',
expired: 'Expiré',
activePlan: 'Plan Actif',
activeUntil: "Actif jusqu'au",
renewBefore: 'Renouveler avant le',
required: 'Un abonnement est requis pour publier des annonces.',
noSubscription: "Pas d'abonnement actif",
noSubscriptionDesc: 'Abonnez-vous pour commencer à publier des annonces.',
subscribe: "S'abonner",
currentPlan: 'Plan Actuel',
choosePlan: 'Choisir un Plan',
saveMonths: 'Économisez 2 mois',
perMonth: '/ mois',
perYear: '/ an',
loadingStatus: "Chargement du statut d'abonnement...",
subscribeTab: 'Abonnement',
},
payment: {
processing: 'Traitement du paiement...',
success: 'Paiement réussi!',
failed: 'Paiement échoué',
cancelled: 'Paiement annulé',
pending: 'Paiement en attente',
printReceipt: 'Imprimer le Reçu',
transactionId: 'ID de Transaction',
date: 'Date',
method: 'Méthode de Paiement',
total: 'Total',
receiptTitle: 'Reçu de Paiement',
paidBy: 'Payé par',
description: 'Description',
backToHome: "Retour à l'Accueil",
tryAgain: 'Réessayer',
checkingStatus: 'Vérification du statut du paiement...',
},
purchase: {
buyNow: 'Acheter Maintenant',
buyFor: 'Acheter pour',
confirmPurchase: "Confirmer l'Achat",
sold: 'Vendu',
alreadySold: 'Cette annonce a été vendue.',
},
},
} as const;
export type Translations = typeof translations['en'];
export default translations;

3
src/index.css Normal file
View File

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

77
src/lib/normalizers.ts Normal file
View File

@@ -0,0 +1,77 @@
import { User, Agency, Listing, Category, Message } from '../types';
export function normalizeUser(u: any): User {
return {
id: u.id,
email: u.email,
name: u.name,
role: u.role,
verified: u.verified,
phone: u.phone ?? undefined,
avatarUrl: u.avatar_url ?? undefined,
createdAt: u.created_at,
};
}
export function normalizeAgency(a: any): Agency {
return {
id: a.id,
userId: a.user_id,
name: a.name,
description: a.description ?? '',
logo: a.logo ?? undefined,
address: a.address ?? '',
phone: a.phone ?? '',
email: a.email ?? '',
website: a.website ?? undefined,
verified: a.verified,
};
}
export function normalizeListing(l: any): Listing {
return {
id: l.id,
title: l.title,
description: l.description,
price: l.price,
images: l.images || [],
status: l.status,
agencyId: l.agency_id,
categoryId: l.category_id,
location: l.location,
listingType: l.listing_type,
condition: l.condition ?? undefined,
negotiable: l.negotiable,
viewsCount: l.views_count,
rejectionReason: l.rejection_reason ?? undefined,
agencyName: l.agency_name ?? undefined,
categoryName: l.category_name ?? undefined,
createdAt: l.created_at,
updatedAt: l.updated_at ?? undefined,
};
}
export function normalizeCategory(c: any): Category {
return {
id: c.id,
name: c.name,
description: c.description ?? '',
icon: c.icon ?? 'tag',
slug: c.slug,
listingCount: c.listing_count ?? 0,
};
}
export function normalizeMessage(m: any): Message {
return {
id: m.id,
listingId: m.listing_id,
name: m.name,
email: m.email,
phone: m.phone ?? undefined,
message: m.message,
agencyId: m.agency_id,
createdAt: m.created_at,
read: m.read,
};
}

11
src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import './i18n';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

81
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,81 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Shield, Users, Globe, Award } from 'lucide-react';
import Button from '../components/common/Button';
import { usePageTitle } from '../hooks/usePageTitle';
const About: React.FC = () => {
usePageTitle('About');
return (
<div className="min-h-screen">
{/* Hero */}
<section className="bg-gradient-to-r from-primary-900 to-primary-800 text-white py-20">
<div className="max-w-4xl mx-auto px-4 text-center">
<h1 className="text-4xl md:text-5xl font-bold mb-6">About Deals24Togo</h1>
<p className="text-xl text-primary-100 max-w-2xl mx-auto">
The trusted marketplace connecting sellers, agencies, and buyers
across Togo and beyond. From real estate to electronics, we make
it easy to buy, sell, and rent.
</p>
</div>
</section>
{/* Values */}
<section className="py-16 max-w-7xl mx-auto px-4">
<h2 className="text-3xl font-bold text-center text-primary-800 mb-12">What Sets Us Apart</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[
{ icon: Shield, title: 'Verified Agencies', desc: 'Every agency is vetted to ensure trust and quality.' },
{ icon: Users, title: 'Admin Review', desc: 'All listings are reviewed before being published.' },
{ icon: Globe, title: 'Multi-Category', desc: 'Real estate, vehicles, electronics, jobs, and more.' },
{ icon: Award, title: 'Free to Browse', desc: 'Browse thousands of listings completely free.' },
].map((item, i) => (
<div key={i} className="text-center p-6">
<div className="w-14 h-14 rounded-full bg-accent-100 flex items-center justify-center mx-auto mb-4">
<item.icon className="h-7 w-7 text-accent-600" />
</div>
<h3 className="text-lg font-semibold text-primary-800 mb-2">{item.title}</h3>
<p className="text-primary-600 text-sm">{item.desc}</p>
</div>
))}
</div>
</section>
{/* Mission */}
<section className="py-16 bg-primary-50">
<div className="max-w-3xl mx-auto px-4 text-center">
<h2 className="text-3xl font-bold text-primary-800 mb-6">Our Mission</h2>
<p className="text-lg text-primary-600 leading-relaxed">
We believe everyone deserves access to a fair, transparent marketplace.
Our platform empowers individuals and businesses to showcase what they
have to offer whether it's a home, a car, professional services, or
a career opportunity — while giving buyers the tools they need to find
exactly what they're looking for.
</p>
</div>
</section>
{/* CTA */}
<section className="py-16 bg-primary-800 text-white">
<div className="max-w-4xl mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-4">Ready to get started?</h2>
<p className="text-primary-100 text-lg mb-8">
Join thousands of users already buying, selling, and renting on Deals24Togo.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link to="/listings">
<Button variant="secondary" size="lg">Browse Listings</Button>
</Link>
<Link to="/register">
<Button variant="outline" size="lg" className="border-white text-white hover:bg-white/10">
Create an Account
</Button>
</Link>
</div>
</div>
</section>
</div>
);
};
export default About;

View File

@@ -0,0 +1,335 @@
import React, { useState } from 'react';
import { Grid, List, CheckCircle, XCircle, Clock, Search } from 'lucide-react';
import { usePageTitle } from '../hooks/usePageTitle';
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 UserManagement from '../components/admin/UserManagement';
import { useListings } from '../contexts/ListingContext';
import { api } from '../services/api';
import { useToast } from '../contexts/ToastContext';
const AdminDashboard: React.FC = () => {
usePageTitle('Admin Dashboard');
const { listings, getListingsByStatus, updateListingStatus } = useListings();
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState('pending');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [searchQuery, setSearchQuery] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
const [bulkRejectReason, setBulkRejectReason] = useState('');
const pendingListings = getListingsByStatus('pending');
const approvedListings = getListingsByStatus('approved');
const rejectedListings = getListingsByStatus('rejected');
const baseListings = activeTab === 'pending'
? pendingListings
: activeTab === 'approved'
? approvedListings
: rejectedListings;
const displayListings = searchQuery.trim()
? baseListings.filter(l =>
l.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
l.location.toLowerCase().includes(searchQuery.toLowerCase()) ||
(l.agencyName || '').toLowerCase().includes(searchQuery.toLowerCase())
)
: baseListings;
const toggleSelect = (id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (selectedIds.size === displayListings.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(displayListings.map(l => l.id)));
}
};
const bulkAction = async (status: 'approved' | 'rejected') => {
if (!selectedIds.size) return;
if (status === 'rejected' && !bulkRejectReason.trim()) {
showToast('Please enter a rejection reason before rejecting listings.', 'error');
return;
}
setBulkLoading(true);
try {
await Promise.all([...selectedIds].map(id =>
updateListingStatus(id, status, status === 'rejected' ? bulkRejectReason.trim() : undefined)
));
showToast(`${selectedIds.size} listing(s) ${status}`, 'success');
setSelectedIds(new Set());
setBulkRejectReason('');
} catch (err: any) {
showToast(err.message || 'Bulk action failed', 'error');
} finally {
setBulkLoading(false);
}
};
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'); setSelectedIds(new Set()); }}
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'); setSelectedIds(new Set()); }}
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'); setSelectedIds(new Set()); }}
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 items-center space-x-2">
<div className="relative">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder="Search listings..."
className="pl-9 pr-4 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-accent-500 w-52"
/>
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
</div>
<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 gap-3">
<div className="flex items-center gap-3">
{displayListings.length > 0 && (
<input
type="checkbox"
checked={selectedIds.size === displayListings.length && displayListings.length > 0}
onChange={toggleSelectAll}
className="h-4 w-4 text-accent-600 border-gray-300 rounded"
title="Select all"
/>
)}
<h2 className="text-xl font-semibold text-primary-800">{getListingCountText()}</h2>
</div>
{selectedIds.size > 0 && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-primary-600">{selectedIds.size} selected</span>
{activeTab !== 'approved' && (
<Button
variant="success"
size="sm"
disabled={bulkLoading}
onClick={() => bulkAction('approved')}
icon={<CheckCircle className="h-4 w-4" />}
>
Approve All
</Button>
)}
{activeTab !== 'rejected' && (
<>
<input
type="text"
value={bulkRejectReason}
onChange={e => setBulkRejectReason(e.target.value)}
placeholder="Rejection reason (required)"
className="px-2 py-1 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-1 focus:ring-error-500 w-52"
/>
<Button
variant="error"
size="sm"
disabled={bulkLoading}
onClick={() => bulkAction('rejected')}
icon={<XCircle className="h-4 w-4" />}
>
Reject All
</Button>
</>
)}
</div>
)}
</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 => (
<div key={listing.id} className="relative">
<input
type="checkbox"
checked={selectedIds.has(listing.id)}
onChange={() => toggleSelect(listing.id)}
className="absolute top-3 left-3 z-10 h-4 w-4 text-accent-600 border-gray-300 rounded"
/>
<ListingReviewCard listing={listing} />
</div>
))}
</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>
{/* Users */}
<div className="mb-12">
<UserManagement />
</div>
</div>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,401 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { PlusCircle, LayoutGrid, Package, MessageCircle, CheckCircle, XCircle, Clock, Building, CreditCard } from 'lucide-react';
import { usePageTitle } from '../hooks/usePageTitle';
import { useToast } from '../contexts/ToastContext';
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 { api } from '../services/api';
import { Subscription } from '../types';
const AgencyDashboard: React.FC = () => {
usePageTitle('Agency Dashboard');
const { user, agency, refreshAgency } = useAuth();
const { listings, deleteListing } = useListings();
const { showToast } = useToast();
const [activeTab, setActiveTab] = useState('listings');
const [isCreatingListing, setIsCreatingListing] = useState(false);
const [editingListingId, setEditingListingId] = useState<string | null>(null);
const [profileForm, setProfileForm] = useState<{
name: string;
description: string;
phone: string;
address: string;
website: string;
logo?: string;
}>({
name: agency?.name || '',
description: agency?.description || '',
phone: agency?.phone || '',
address: agency?.address || '',
website: agency?.website || '',
});
const [savingProfile, setSavingProfile] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [subscription, setSubscription] = useState<{ has_active_subscription: boolean; subscription: Subscription | null } | null>(null);
useEffect(() => {
api.messages.unreadCount()
.then((data: any) => setUnreadCount(data.count ?? data.unread_count ?? 0))
.catch(() => {});
api.subscriptions.me()
.then((data: any) => setSubscription(data))
.catch(() => {});
}, []);
// Sync form when agency data loads (it may be null on first render)
useEffect(() => {
if (!agency) return;
setProfileForm({
name: agency.name || '',
description: agency.description || '',
phone: agency.phone || '',
address: agency.address || '',
website: agency.website || '',
logo: agency.logo,
});
// Auto-switch to profile tab if agency profile is incomplete (new registration)
if (!agency.description && !agency.phone && !agency.address) {
setActiveTab('profile');
}
}, [agency]);
// All listings in context belong to this agency (fetched via api.listings.mine())
const agencyListings = listings;
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);
setEditingListingId(null);
};
const handleDelete = async (id: string) => {
if (!window.confirm('Delete this listing?')) return;
try {
await deleteListing(id);
showToast('Listing deleted', 'success');
} catch (err: any) {
showToast(err.message || 'Failed to delete listing', 'error');
}
};
const editingListing = editingListingId
? agencyListings.find(l => l.id === editingListingId)
: undefined;
const handleLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingLogo(true);
try {
const result = await api.uploads.image(file);
const url = result.url || result.urls?.[0];
if (url) {
setLogoPreview(url);
setProfileForm(prev => ({ ...prev, logo: url }));
}
} catch (err: any) {
showToast(err.message || 'Failed to upload logo', 'error');
} finally {
setUploadingLogo(false);
}
};
const handleProfileSave = async (e: React.FormEvent) => {
e.preventDefault();
if (!agency) return;
setSavingProfile(true);
try {
await api.agencies.update(agency.id, profileForm);
await refreshAgency();
showToast('Agency profile updated!', 'success');
} catch (err: any) {
showToast(err.message || 'Failed to update profile', 'error');
} finally {
setSavingProfile(false);
}
};
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 || user?.name} Dashboard
</h1>
{activeTab === 'listings' && !isCreatingListing && !editingListingId && (
<Button
variant="secondary"
onClick={() => setIsCreatingListing(true)}
icon={<PlusCircle className="h-4 w-4" />}
>
Create New Listing
</Button>
)}
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
{[
{ icon: Package, label: 'Total Listings', value: agencyListings.length, color: 'primary' },
{ icon: Clock, label: 'Pending', value: pendingCount, color: 'warning' },
{ icon: CheckCircle, label: 'Approved', value: approvedCount, color: 'success' },
{ icon: XCircle, label: 'Rejected', value: rejectedCount, color: 'error' },
].map(({ icon: Icon, label, value, color }) => (
<Card key={label} className={`bg-${color}-50`}>
<CardContent className="p-4">
<div className="flex items-center">
<div className={`p-3 rounded-full bg-${color}-100 text-${color}-700 mr-4`}>
<Icon className="h-6 w-6" />
</div>
<div>
<p className={`text-sm text-${color}-500`}>{label}</p>
<p className={`text-2xl font-bold text-${color}-800`}>{value}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Tabs */}
{!isCreatingListing && !editingListingId && (
<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
{agencyListings.length > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold rounded-full bg-primary-200 text-primary-800 leading-none">
{agencyListings.length}
</span>
)}
</button>
<button
onClick={() => { setActiveTab('messages'); setUnreadCount(0); }}
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
{unreadCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-bold rounded-full bg-error-500 text-white leading-none">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
<button
onClick={() => setActiveTab('profile')}
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
activeTab === 'profile' ? 'bg-white shadow-sm text-primary-800' : 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
}`}
>
<Building className="h-4 w-4 mr-2" />
Agency Profile
</button>
<button
onClick={() => setActiveTab('subscription')}
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
activeTab === 'subscription' ? 'bg-white shadow-sm text-primary-800' : 'text-primary-600 hover:text-primary-800 hover:bg-white/60'
}`}
>
<CreditCard className="h-4 w-4 mr-2" />
Abonnement
{subscription && !subscription.has_active_subscription && (
<span className="ml-1.5 inline-flex items-center justify-center w-2 h-2 rounded-full bg-warning-500" />
)}
</button>
</div>
)}
{/* Content */}
{activeTab === 'listings' && (
<>
{subscription && !subscription.has_active_subscription && (
<div className="mb-4 p-3 bg-warning-50 border border-warning-200 rounded-lg flex items-center justify-between text-sm text-warning-800">
<span>Un abonnement actif est requis pour publier des annonces.</span>
<Link to="/subscription" className="ml-3 font-medium underline hover:no-underline flex-shrink-0">
S'abonner →
</Link>
</div>
)}
{isCreatingListing ? (
<ListingForm hasSubscription={subscription?.has_active_subscription ?? true} onSuccess={handleEditComplete} />
) : editingListingId ? (
<ListingForm hasSubscription={subscription?.has_active_subscription ?? true} initialListing={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 text-xs"
onClick={() => setEditingListingId(listing.id)}
>
Edit
</Button>
<Button
variant="error"
size="sm"
className="px-2 py-1 bg-white bg-opacity-90 hover:bg-opacity-100 text-xs"
onClick={() => handleDelete(listing.id)}
>
Delete
</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 />}
{activeTab === 'subscription' && (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-primary-800">Abonnement</h2>
</CardHeader>
<CardContent>
{subscription?.has_active_subscription && subscription.subscription ? (
<div className="space-y-4">
<div className="p-4 bg-success-50 border border-success-200 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-5 w-5 text-success-600" />
<span className="font-semibold text-success-800">
{subscription.subscription.plan === 'monthly' ? 'Plan Mensuel' : 'Plan Annuel'} Actif
</span>
</div>
<p className="text-sm text-success-700">
Expire le {new Date(subscription.subscription.ends_at).toLocaleDateString('fr-TG')}
</p>
</div>
<Link to="/subscription">
<Button variant="outline" icon={<CreditCard className="h-4 w-4" />}>
Renouveler / Changer de plan
</Button>
</Link>
</div>
) : (
<div className="space-y-4">
<p className="text-primary-700">Vous n'avez pas d'abonnement actif. Abonnez-vous pour publier des annonces.</p>
<Link to="/subscription">
<Button variant="secondary" icon={<CreditCard className="h-4 w-4" />}>
Voir les plans d'abonnement
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
)}
{activeTab === 'profile' && (
<Card>
<CardHeader>
<h2 className="text-xl font-semibold text-primary-800">Edit Agency Profile</h2>
</CardHeader>
<CardContent>
<form onSubmit={handleProfileSave} className="space-y-4 max-w-lg">
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-primary-700 mb-2">Agency Logo</label>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0 flex items-center justify-center">
{(logoPreview || agency?.logo) ? (
<img src={logoPreview || agency?.logo} alt="Logo" className="w-full h-full object-cover" />
) : (
<Building className="h-8 w-8 text-gray-400" />
)}
</div>
<label className="cursor-pointer">
<input type="file" accept="image/*" className="hidden" onChange={handleLogoUpload} />
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 border border-gray-300 rounded-md text-sm text-primary-700 hover:bg-gray-50 transition-colors">
{uploadingLogo ? 'Uploading...' : 'Upload Logo'}
</span>
</label>
</div>
</div>
{[
{ label: 'Agency Name', name: 'name', type: 'text', required: true },
{ label: 'Phone', name: 'phone', type: 'tel', required: false },
{ label: 'Address', name: 'address', type: 'text', required: false },
{ label: 'Website', name: 'website', type: 'url', required: false },
].map(({ label, name, type, required }) => (
<div key={name}>
<label className="block text-sm font-medium text-primary-700 mb-1">
{label}{required && <span className="text-error-500"> *</span>}
</label>
<input
type={type}
value={(profileForm as any)[name]}
onChange={e => setProfileForm(prev => ({ ...prev, [name]: e.target.value }))}
required={required}
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 className="block text-sm font-medium text-primary-700 mb-1">Description</label>
<textarea
value={profileForm.description}
onChange={e => setProfileForm(prev => ({ ...prev, description: e.target.value }))}
rows={4}
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 justify-end">
<Button type="submit" variant="secondary" disabled={savingProfile}>
{savingProfile ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
</div>
);
};
export default AgencyDashboard;

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { MapPin, Phone, Mail, Globe, CheckCircle, Building } from 'lucide-react';
import Card, { CardContent } from '../components/common/Card';
import ListingCard from '../components/listings/ListingCard';
import Button from '../components/common/Button';
import { Agency, Listing } from '../types';
import { api } from '../services/api';
import { normalizeAgency, normalizeListing } from '../lib/normalizers';
import { usePageTitle } from '../hooks/usePageTitle';
import { useAuth } from '../contexts/AuthContext';
import { useI18n } from '../contexts/I18nContext';
const AgencyPublicProfile: React.FC = () => {
const { t } = useI18n();
const { id } = useParams<{ id: string }>();
const { user } = useAuth();
const [agency, setAgency] = useState<Agency | null>(null);
usePageTitle(agency?.name);
const [agencyListings, setAgencyListings] = useState<Listing[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
const [listingsError, setListingsError] = useState(false);
const [favIds, setFavIds] = useState<Set<string>>(new Set());
useEffect(() => {
if (!id) return;
const load = async () => {
try {
const agencyData = await api.agencies.get(id);
setAgency(normalizeAgency(agencyData));
} catch {
setNotFound(true);
setIsLoading(false);
return;
}
try {
const listingsData = await api.listings.list({ agency_id: id, page_size: 50 });
setAgencyListings((listingsData.listings || []).map(normalizeListing));
} catch {
setListingsError(true);
} finally {
setIsLoading(false);
}
};
load();
}, [id]);
useEffect(() => {
if (!user || agencyListings.length === 0) return;
api.favorites.list()
.then((data: any) => {
const items = data.favorites || data || [];
setFavIds(new Set(
items.map((f: any) => f.listing_id || f.listingId || f.listings?.id).filter(Boolean)
));
})
.catch(() => {});
}, [user, agencyListings]);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="animate-pulse">
<div className="h-32 bg-gray-200 rounded-lg mb-6" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map(i => <div key={i} className="h-64 bg-gray-200 rounded-lg" />)}
</div>
</div>
</div>
);
}
if (notFound || !agency) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<Building className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-primary-800 mb-2">{t.agency.notFound}</h1>
<p className="text-primary-600 mb-6">{t.agency.notFoundDesc}</p>
<Link to="/listings">
<Button variant="primary">{t.agency.browseListings}</Button>
</Link>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<Card className="mb-8">
<CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center gap-6">
<div className="w-24 h-24 rounded-lg bg-gray-100 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-4 text-gray-400" />
)}
</div>
<div className="flex-grow">
<div className="flex items-center gap-2 mb-1">
<h1 className="text-2xl font-bold text-primary-800">{agency.name}</h1>
{agency.verified && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-success-50 text-success-700">
<CheckCircle className="h-3 w-3 mr-1" />
{t.agency.verified}
</span>
)}
</div>
<p className="text-primary-600 mb-3">{agency.description}</p>
<div className="flex flex-wrap gap-4 text-sm text-primary-500">
{agency.address && (
<span className="flex items-center"><MapPin className="h-4 w-4 mr-1" />{agency.address}</span>
)}
{agency.phone && (
<span className="flex items-center"><Phone className="h-4 w-4 mr-1" />{agency.phone}</span>
)}
{agency.email && (
<span className="flex items-center"><Mail className="h-4 w-4 mr-1" />{agency.email}</span>
)}
{agency.website && (
<a
href={agency.website}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-accent-600 hover:underline"
>
<Globe className="h-4 w-4 mr-1" />{t.listing.website}
</a>
)}
</div>
</div>
<div className="text-center">
<p className="text-3xl font-bold text-accent-600">{agencyListings.length}</p>
<p className="text-sm text-primary-500">{t.agency.activeListings}</p>
</div>
</div>
</CardContent>
</Card>
<h2 className="text-xl font-semibold text-primary-800 mb-4">{t.agency.listingsBy} {agency.name}</h2>
{listingsError ? (
<Card>
<CardContent className="text-center py-12">
<p className="text-error-600 mb-2">{t.agency.failedToLoad}</p>
<Button variant="outline" size="sm" onClick={() => {
setListingsError(false);
api.listings.list({ agency_id: id!, page_size: 50 })
.then(data => setAgencyListings((data.listings || []).map(normalizeListing)))
.catch(() => setListingsError(true));
}}>{t.agency.retry}</Button>
</CardContent>
</Card>
) : agencyListings.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{agencyListings.map(listing => (
<ListingCard
key={listing.id}
listing={listing}
initialFav={user ? favIds.has(listing.id) : undefined}
/>
))}
</div>
) : (
<Card>
<CardContent className="text-center py-12">
<p className="text-primary-500">{t.agency.noListings}</p>
</CardContent>
</Card>
)}
</div>
);
};
export default AgencyPublicProfile;

100
src/pages/Categories.tsx Normal file
View File

@@ -0,0 +1,100 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Tag } from 'lucide-react';
import { api } from '../services/api';
import { normalizeCategory } from '../lib/normalizers';
import { Category } from '../types';
import { usePageTitle } from '../hooks/usePageTitle';
import { useI18n } from '../contexts/I18nContext';
import Button from '../components/common/Button';
const Categories: React.FC = () => {
const { t } = useI18n();
usePageTitle('Categories');
const [categories, setCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
const load = () => {
setIsLoading(true);
setError(false);
api.categories.list()
.then((data: any) => {
const list = (data.categories || data || []).map(normalizeCategory);
setCategories(list);
})
.catch(() => setError(true))
.finally(() => setIsLoading(false));
};
useEffect(() => {
load();
}, []);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-16">
<h1 className="text-3xl font-bold text-center mb-4">{t.categories.title}</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mt-12">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="animate-pulse h-24 bg-gray-200 rounded-xl" />
))}
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 py-16">
<h1 className="text-3xl font-bold text-center mb-4">
{t.categories.title}
</h1>
<p className="text-center text-gray-600 mb-12">
{t.categories.subtitle}
</p>
{error ? (
<div className="text-center py-12">
<p className="text-error-600 mb-4">{t.categories.errorLoading}</p>
<Button variant="outline" onClick={load}>{t.categories.retry}</Button>
</div>
) : categories.length === 0 ? (
<p className="text-center text-primary-500">{t.categories.none}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.map((category) => (
<Link
key={category.id}
to={`/listings?category=${category.slug || category.id}`}
className="group bg-white border rounded-xl p-6 flex justify-between items-center hover:shadow-lg transition"
>
<div className="flex gap-4">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<Tag className="text-gray-600 group-hover:text-teal-600" />
</div>
<div>
<h3 className="text-lg font-semibold group-hover:text-teal-600">
{category.name}
</h3>
{category.description && (
<p className="text-gray-600 text-sm">
{category.description}
</p>
)}
</div>
</div>
<span className="text-gray-400 group-hover:text-teal-600 text-xl">
</span>
</Link>
))}
</div>
)}
</div>
);
};
export default Categories;

108
src/pages/Favorites.tsx Normal file
View File

@@ -0,0 +1,108 @@
import React, { useState, useEffect } from 'react';
import { Heart, Trash2 } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import Card, { CardContent } from '../components/common/Card';
import ListingCard from '../components/listings/ListingCard';
import Button from '../components/common/Button';
import { useAuth } from '../contexts/AuthContext';
import { api } from '../services/api';
import { normalizeListing } from '../lib/normalizers';
import { Listing } from '../types';
import { usePageTitle } from '../hooks/usePageTitle';
import { useI18n } from '../contexts/I18nContext';
const Favorites: React.FC = () => {
const { t } = useI18n();
usePageTitle('My Favorites');
const { user } = useAuth();
const navigate = useNavigate();
const [favorites, setFavorites] = useState<{ id: string; listing: Listing }[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
if (!user) {
navigate('/login');
return;
}
api.favorites.list()
.then((data: any) => {
const items = (data.favorites || data || []).map((f: any) => ({
id: f.listing_id || f.id,
listing: normalizeListing(f.listing || f.listings || f),
}));
setFavorites(items);
})
.catch(() => setFavorites([]))
.finally(() => setIsLoading(false));
}, [user, navigate]);
const removeFavorite = async (listingId: string) => {
try {
await api.favorites.remove(listingId);
setFavorites((prev) => prev.filter((f) => f.id !== listingId));
} catch {
// silently fail — item stays in list
}
};
if (isLoading) {
return (
<div className="max-w-7xl mx-auto px-4 py-12">
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-48"></div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="h-64 bg-gray-200 rounded-lg"></div>
))}
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<Heart className="h-7 w-7 text-accent-600 mr-3" />
<h1 className="text-3xl font-bold text-primary-800">{t.favorites.title}</h1>
</div>
<p className="text-primary-500">
{favorites.length} {favorites.length !== 1 ? t.favorites.savedPlural : t.favorites.saved}
</p>
</div>
{favorites.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{favorites.map((fav) => (
<div key={fav.id} className="relative">
<ListingCard listing={fav.listing} initialFav={true} />
<button
onClick={() => removeFavorite(fav.id)}
className="absolute top-2 right-2 bg-white rounded-full p-2 shadow-md hover:bg-error-50 transition"
title={t.favorites.removeFromFavorites}
>
<Trash2 className="h-4 w-4 text-error-500" />
</button>
</div>
))}
</div>
) : (
<Card>
<CardContent className="text-center py-16">
<Heart className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-primary-800 mb-2">{t.favorites.none}</h2>
<p className="text-primary-500 mb-6">
{t.favorites.noneDesc}
</p>
<Link to="/listings">
<Button variant="secondary">{t.favorites.browseListings}</Button>
</Link>
</CardContent>
</Card>
)}
</div>
);
};
export default Favorites;

View File

@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { Mail, ArrowLeft, CheckCircle } from 'lucide-react';
import Button from '../components/common/Button';
import { api } from '../services/api';
import { usePageTitle } from '../hooks/usePageTitle';
import { useI18n } from '../contexts/I18nContext';
const ForgotPassword: React.FC = () => {
const { t } = useI18n();
usePageTitle('Forgot Password');
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [error, setError] = useState('');
const [resendsLeft, setResendsLeft] = useState(2);
const [resending, setResending] = useState(false);
const handleResend = async () => {
if (!resendsLeft) return;
setResending(true);
try {
await api.auth.requestPasswordReset(email);
setResendsLeft(n => n - 1);
} catch {
// silent
} finally {
setResending(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!email.trim()) {
setError(t.forgotPassword.emailRequired);
return;
}
setIsLoading(true);
try {
await api.auth.requestPasswordReset(email);
setIsSubmitted(true);
} catch (err: any) {
setError(err.message || t.forgotPassword.error);
} finally {
setIsLoading(false);
}
};
if (isSubmitted) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full text-center">
<div className="bg-white py-8 px-6 shadow-md rounded-lg">
<CheckCircle className="h-16 w-16 text-success-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-primary-800 mb-2">{t.forgotPassword.checkEmail}</h1>
<p className="text-primary-600 mb-6">
<strong>{email}</strong> {t.forgotPassword.checkEmailDesc}
</p>
<Link to="/login">
<Button variant="primary" icon={<ArrowLeft className="h-4 w-4" />}>
{t.forgotPassword.backToSignIn}
</Button>
</Link>
{resendsLeft > 0 && (
<p className="mt-4 text-sm text-primary-500">
{t.forgotPassword.didntReceive}{' '}
<button
onClick={handleResend}
disabled={resending}
className="text-accent-600 hover:text-accent-800 underline disabled:opacity-60"
>
{resending ? t.forgotPassword.resending : t.forgotPassword.resend}
</button>
</p>
)}
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-800 mb-2">{t.forgotPassword.title}</h1>
<p className="text-primary-600">
{t.forgotPassword.subtitle}
</p>
</div>
<div className="bg-white py-8 px-6 shadow-md rounded-lg">
{error && (
<div className="mb-4 p-3 rounded-md bg-error-50 text-error-700 text-sm">{error}</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-primary-700 mb-1">
{t.forgotPassword.emailLabel}
</label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<Button type="submit" variant="secondary" fullWidth disabled={isLoading}>
{isLoading ? t.forgotPassword.submitting : t.forgotPassword.submit}
</Button>
</form>
<div className="mt-6 text-center">
<Link to="/login" className="text-sm text-accent-600 hover:text-accent-800 flex items-center justify-center">
<ArrowLeft className="h-4 w-4 mr-1" />
{t.forgotPassword.backToSignIn}
</Link>
</div>
</div>
</div>
</div>
);
};
export default ForgotPassword;

259
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,259 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } 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 { Listing, Category } from '../types';
import { api } from '../services/api';
import { normalizeListing, normalizeCategory } from '../lib/normalizers';
import { useI18n } from '../contexts/I18nContext';
import { usePageTitle } from '../hooks/usePageTitle';
import { useAuth } from '../contexts/AuthContext';
const Home: React.FC = () => {
const { t } = useI18n();
const { user } = useAuth();
const navigate = useNavigate();
usePageTitle();
const [featuredListings, setFeaturedListings] = useState<Listing[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [fetchError, setFetchError] = useState(false);
const [heroSearch, setHeroSearch] = useState('');
const [heroCategory, setHeroCategory] = useState('');
const [favIds, setFavIds] = useState<Set<string>>(new Set());
const handleHeroSearch = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (heroSearch.trim()) params.set('search', heroSearch.trim());
if (heroCategory) params.set('category', heroCategory);
navigate(`/listings?${params.toString()}`);
};
const loadHomeData = async () => {
setLoading(true);
setFetchError(false);
try {
const [listingsData, categoriesData] = await Promise.all([
api.listings.featured(),
api.categories.list(),
]);
setFeaturedListings((listingsData.listings || listingsData || []).map(normalizeListing));
setCategories((categoriesData.categories || categoriesData || []).map(normalizeCategory));
} catch (err) {
console.error('Failed to load home data:', err);
setFetchError(true);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadHomeData();
}, []);
// Batch-load favorites for featured listings
useEffect(() => {
if (!user || loading) return;
api.favorites.list()
.then((data: any) => {
const items = data.favorites || data || [];
const ids = new Set<string>(
items.map((f: any) => f.listing_id || f.listingId || f.listings?.id).filter(Boolean)
);
setFavIds(ids);
})
.catch(() => {});
}, [user, loading]);
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 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">
{t.home.heroTitle}
</h1>
<p className="text-xl md:text-2xl mb-8 text-primary-100">
{t.home.heroSubtitle}
</p>
<form onSubmit={handleHeroSearch} 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"
value={heroSearch}
onChange={e => setHeroSearch(e.target.value)}
placeholder={t.home.searchPlaceholder}
className="w-full pl-10 pr-4 py-3 rounded-md border-0 focus:ring-2 focus:ring-accent-500 text-gray-800"
/>
<Search className="absolute left-3 top-3 text-gray-400 h-5 w-5" />
</div>
<div className="w-full md:w-48">
<select
value={heroCategory}
onChange={e => setHeroCategory(e.target.value)}
className="w-full px-4 py-3 rounded-md border-0 focus:ring-2 focus:ring-accent-500 text-gray-800"
>
<option value="">{t.home.allCategories}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.slug}>{cat.name}</option>
))}
</select>
</div>
<Button type="submit" variant="secondary" size="lg">{t.home.search}</Button>
</div>
</form>
<div className="flex flex-wrap gap-4">
<Link to="/listings">
<Button size="lg">{t.home.browseAll}</Button>
</Link>
<Link to="/categories">
<Button variant="outline" size="lg" className="!text-white border-white hover:bg-white/10">
{t.home.exploreCategories}
</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">{t.home.browseByCategory}</h2>
<p className="text-lg text-primary-600 max-w-2xl mx-auto">{t.home.browseByCategorySubtitle}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{categories.slice(0, 6).map(cat => (
<Link key={cat.id} to={`/listings?category=${cat.slug}`}>
<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">
{cat.name}
</h3>
<p className="text-sm text-primary-600">{cat.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">{t.home.viewAllCategories}</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">{t.home.featuredListings}</h2>
<p className="text-lg text-primary-600">{t.home.featuredSubtitle}</p>
</div>
<Link to="/listings">
<Button variant="outline" className="hidden sm:flex">
{t.home.viewAllListings}
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{[...Array(4)].map((_, i) => (
<div key={i} className="h-64 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : fetchError ? (
<div className="text-center py-12">
<p className="text-primary-600 mb-4">Failed to load listings. Please try again.</p>
<Button variant="outline" onClick={loadHomeData}>Retry</Button>
</div>
) : featuredListings.length > 0 ? (
<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}
initialFav={user ? favIds.has(listing.id) : undefined}
/>
))}
</div>
) : (
<p className="text-center text-primary-500 py-12">No featured listings yet.</p>
)}
<div className="text-center mt-10 sm:hidden">
<Link to="/listings">
<Button variant="outline">{t.home.viewAllListings}</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">{t.home.howItWorks}</h2>
<p className="text-lg text-primary-600 max-w-2xl mx-auto">{t.home.howItWorksSubtitle}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{[
{ key: 'verified', icon: Building2, title: t.home.verifiedAgencies, desc: t.home.verifiedAgenciesDesc },
{ key: 'approval', icon: Star, title: t.home.adminApproval, desc: t.home.adminApprovalDesc },
{ key: 'contact', icon: MapPin, title: t.home.directContact, desc: t.home.directContactDesc },
].map(({ key, icon: Icon, title, desc }) => (
<div key={key} 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">
<Icon className="h-8 w-8" />
</div>
<h3 className="text-xl font-semibold text-primary-800 mb-2">{title}</h3>
<p className="text-primary-600">{desc}</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">{t.home.ctaTitle}</h2>
<p className="text-xl text-primary-100 mb-6 md:mb-0">{t.home.ctaSubtitle}</p>
</div>
<div className="md:w-1/3 text-center md:text-right">
<Link to="/register">
<Button variant="secondary" size="lg">{t.home.registerAsAgency}</Button>
</Link>
</div>
</div>
</div>
</section>
</div>
);
};
export default Home;

369
src/pages/ListingDetail.tsx Normal file
View File

@@ -0,0 +1,369 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { MapPin, ArrowLeft, ChevronLeft, ChevronRight, Building, Eye, Share2, ShoppingCart } 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 { api } from '../services/api';
import { normalizeListing, normalizeAgency } from '../lib/normalizers';
import { usePageTitle } from '../hooks/usePageTitle';
import { useToast } from '../contexts/ToastContext';
import { useI18n } from '../contexts/I18nContext';
import { useAuth } from '../contexts/AuthContext';
const ListingDetail: React.FC = () => {
const { t } = useI18n();
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showToast } = useToast();
const { user } = useAuth();
const [listing, setListing] = useState<Listing | null>(null);
const [buying, setBuying] = useState(false);
const [agency, setAgency] = useState<Agency | null>(null);
const [agencyLoading, setAgencyLoading] = useState(true);
const [activeImageIndex, setActiveImageIndex] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
usePageTitle(listing?.title);
useEffect(() => {
if (!id) return;
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior });
const load = async () => {
try {
const data = await api.listings.get(id);
const normalized = normalizeListing(data);
setListing(normalized);
// Fetch agency (non-fatal)
setAgencyLoading(true);
api.agencies.get(normalized.agencyId)
.then(agencyData => setAgency(normalizeAgency(agencyData)))
.catch(() => {})
.finally(() => setAgencyLoading(false));
} catch {
setNotFound(true);
} finally {
setIsLoading(false);
}
};
load();
}, [id]);
const formatter = new Intl.NumberFormat('fr-TG', {
style: 'currency',
currency: 'XOF',
minimumFractionDigits: 0,
});
const handleBuy = async () => {
if (!listing) return;
if (!user) {
navigate('/login', { state: { from: { pathname: `/listings/${id}` } } });
return;
}
setBuying(true);
try {
const result = await api.payments.initiate({ type: 'purchase', listing_id: listing.id }) as any;
window.location.href = result.payment_url;
} catch (err: any) {
showToast(err.message || 'Failed to initiate purchase', 'error');
setBuying(false);
}
};
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 className="h-4 bg-gray-200 rounded w-1/2 mb-12" />
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="md:col-span-2">
<div className="h-72 bg-gray-200 rounded-lg mb-6" />
<div className="h-4 bg-gray-200 rounded w-full mb-4" />
<div className="h-4 bg-gray-200 rounded w-3/4" />
</div>
<div>
<div className="h-64 bg-gray-200 rounded" />
</div>
</div>
</div>
</div>
);
}
if (notFound || !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">{t.listing.notFound}</h1>
<p className="text-primary-600 mb-6">{t.listing.notFoundDesc}</p>
<Link to="/listings">
<Button variant="primary">{t.listing.browseAll}</Button>
</Link>
</div>
);
}
const nextImage = () =>
setActiveImageIndex(i => (i === listing.images.length - 1 ? 0 : i + 1));
const prevImage = () =>
setActiveImageIndex(i => (i === 0 ? listing.images.length - 1 : i - 1));
const AgencyCardSkeleton = () => (
<div className="p-4 border border-gray-200 rounded-lg animate-pulse">
<div className="flex items-center mb-4">
<div className="w-12 h-12 rounded-full bg-gray-200 flex-shrink-0" />
<div className="ml-4 flex-1">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-3 bg-gray-200 rounded w-1/2" />
</div>
</div>
<div className="space-y-2 mb-4">
<div className="h-3 bg-gray-200 rounded w-full" />
<div className="h-3 bg-gray-200 rounded w-2/3" />
</div>
<div className="h-9 bg-gray-200 rounded-md" />
</div>
);
const AgencyCard = () => agency ? (
<div className="p-4 border border-gray-200 rounded-lg">
<Link to={`/agencies/${agency.id}`} className="flex items-center mb-4 hover:opacity-80 transition-opacity">
<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 hover:text-accent-600">{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">
{t.listing.verifiedAgency}
</span>
)}
</div>
</Link>
<div className="text-sm text-primary-600 space-y-1 mb-4">
{agency.address && <p>{agency.address}</p>}
{agency.phone && <p>{agency.phone}</p>}
{agency.email && <p>{agency.email}</p>}
</div>
<div className="flex flex-wrap gap-2 mt-3">
<Link
to={`/agencies/${agency.id}`}
className="text-accent-600 hover:text-accent-800 hover:underline text-sm"
>
{t.listing.viewAllListings}
</Link>
{agency.website && (
<>
<span className="text-gray-300">|</span>
<a
href={agency.website}
target="_blank"
rel="noopener noreferrer"
className="text-accent-600 hover:text-accent-800 hover:underline text-sm"
>
{t.listing.website}
</a>
</>
)}
</div>
{agency.phone && (
<a
href={`https://wa.me/${agency.phone.replace(/\D/g, '')}?text=${encodeURIComponent(`Bonjour, je suis intéressé par votre annonce : ${listing.title}`)}`}
target="_blank"
rel="noopener noreferrer"
className="mt-3 flex items-center justify-center gap-2 w-full py-2 px-4 bg-[#25D366] hover:bg-[#1ebe5d] text-white rounded-md text-sm font-medium transition-colors"
>
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
{t.listing.whatsapp}
</a>
)}
</div>
) : null;
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => navigate(-1)}
className="text-primary-600 hover:text-primary-800 flex items-center"
>
<ArrowLeft className="h-4 w-4 mr-1" />
<span>{t.listing.backToListings}</span>
</button>
<button
onClick={() => {
navigator.clipboard.writeText(window.location.href)
.then(() => showToast(t.listing.linkCopied, 'success'))
.catch(() => showToast(t.listing.couldNotCopy, 'error'));
}}
className="flex items-center gap-1.5 text-sm text-primary-600 hover:text-primary-800 border border-gray-300 rounded-md px-3 py-1.5 hover:bg-gray-50 transition-colors"
>
<Share2 className="h-4 w-4" />
{t.listing.share}
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Left column */}
<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>
<StatusBadge status={listing.status} />
</div>
{listing.status === 'rejected' && listing.rejectionReason && (
<div className="mb-3 p-3 bg-error-50 border border-error-200 rounded-md text-sm text-error-700">
<strong>{t.listing.rejectionReason}</strong> {listing.rejectionReason}
</div>
)}
<div className="flex flex-wrap gap-3 mb-2">
{listing.categoryName && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
{listing.categoryName}
</span>
)}
{listing.listingType && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-accent-100 text-accent-800">
{listing.listingType === 'rent' ? t.listing.forRent : t.listing.forSale}
</span>
)}
{listing.condition && (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 capitalize">
{listing.condition}
</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">
{t.listing.postedOn} {new Date(listing.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex items-baseline gap-3 my-2">
<h2 className="text-2xl font-bold text-accent-700">
{formatter.format(listing.price)}
{listing.listingType === 'rent' && (
<span className="text-base font-normal text-primary-500">{t.listing.perMonth}</span>
)}
</h2>
{listing.negotiable && (
<span className="text-sm text-success-600 font-medium">{t.listing.negotiable}</span>
)}
{listing.viewsCount != null && (
<span className="flex items-center text-xs text-primary-400 ml-auto">
<Eye className="h-3.5 w-3.5 mr-1" />
{listing.viewsCount} {listing.viewsCount === 1 ? t.listing.view : t.listing.views}
</span>
)}
</div>
{/* Buy button — only for visitors on approved listings */}
{listing.status === 'approved' && user?.role === 'visitor' && (
<div className="mt-3">
<Button
variant="secondary"
onClick={handleBuy}
disabled={buying}
icon={<ShoppingCart className="h-4 w-4" />}
>
{buying ? t.payment.processing : `${t.purchase.buyFor} ${formatter.format(listing.price)}`}
</Button>
</div>
)}
{listing.status === 'sold' && (
<div className="mt-3 inline-flex items-center gap-2 px-3 py-1.5 bg-primary-100 text-primary-800 rounded-md text-sm font-medium border border-primary-300">
{t.purchase.sold}
</div>
)}
</div>
{/* Image gallery */}
<div className="mb-8">
<div className="relative bg-gray-100 rounded-lg overflow-hidden">
<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}
loading="lazy"
className="w-full h-80 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';
}}
/>
{listing.images.length > 1 && (
<>
<button
onClick={prevImage}
className="absolute left-2 top-1/2 -translate-y-1/2 bg-white bg-opacity-75 rounded-full p-2 hover:bg-opacity-100 focus:outline-none"
>
<ChevronLeft className="h-6 w-6 text-primary-800" />
</button>
<button
onClick={nextImage}
className="absolute right-2 top-1/2 -translate-y-1/2 bg-white bg-opacity-75 rounded-full p-2 hover:bg-opacity-100 focus:outline-none"
>
<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={`Thumbnail ${index + 1}`} loading="lazy" className="w-full h-full object-cover" />
</button>
))}
</div>
)}
</div>
{/* Description */}
<div className="mb-8">
<h3 className="text-xl font-semibold text-primary-800 mb-4">{t.listing.description}</h3>
<p className="text-primary-700 whitespace-pre-line">{listing.description}</p>
</div>
{/* Agency (mobile) */}
{(agencyLoading || agency) && (
<div className="md:hidden mb-8">
<h3 className="text-xl font-semibold text-primary-800 mb-4">{t.listing.listedBy}</h3>
{agencyLoading ? <AgencyCardSkeleton /> : <AgencyCard />}
</div>
)}
</div>
{/* Right column */}
<div>
{(agencyLoading || agency) && (
<div className="hidden md:block mb-6">
<h3 className="text-xl font-semibold text-primary-800 mb-4">{t.listing.listedBy}</h3>
{agencyLoading ? <AgencyCardSkeleton /> : <AgencyCard />}
</div>
)}
<ContactForm listing={listing} />
</div>
</div>
</div>
);
};
export default ListingDetail;

273
src/pages/Listings.tsx Normal file
View File

@@ -0,0 +1,273 @@
import { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
import ListingCard from '../components/listings/ListingCard';
import Button from '../components/common/Button';
import { Listing, Category } from '../types';
import { api } from '../services/api';
import { normalizeListing, normalizeCategory } from '../lib/normalizers';
import { useI18n } from '../contexts/I18nContext';
import { usePageTitle } from '../hooks/usePageTitle';
import { useAuth } from '../contexts/AuthContext';
const PAGE_SIZE = 20;
const Listings: React.FC = () => {
const { t } = useI18n();
const { user } = useAuth();
usePageTitle('Listings');
const [searchParams, setSearchParams] = useSearchParams();
const [listings, setListings] = useState<Listing[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [total, setTotal] = useState(0);
const [favIds, setFavIds] = useState<Set<string>>(new Set());
const categorySlug = 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';
const currentPage = parseInt(searchParams.get('page') || '1', 10);
const [filters, setFilters] = useState({
category: categorySlug,
priceMin: priceMinFilter,
priceMax: priceMaxFilter,
search: searchFilter,
});
// Load categories once
useEffect(() => {
api.categories.list()
.then((data: any) => setCategories((data.categories || data || []).map(normalizeCategory)))
.catch(console.error);
}, []);
// Batch-load favorites for logged-in users whenever listings change
useEffect(() => {
if (!user) return;
api.favorites.list()
.then((data: any) => {
const items = data.favorites || data || [];
const ids = new Set<string>(
items.map((f: any) => f.listing_id || f.listingId || f.listings?.id).filter(Boolean)
);
setFavIds(ids);
})
.catch(() => {});
}, [user]);
// Fetch listings whenever URL params change
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const params: Record<string, any> = {
page: currentPage,
page_size: PAGE_SIZE,
sort_by: sortBy === 'price_low' ? 'price_asc' : sortBy === 'price_high' ? 'price_desc' : sortBy,
};
if (searchFilter) params.search = searchFilter;
if (categorySlug) params.category = categorySlug;
if (priceMinFilter) params.min_price = priceMinFilter;
if (priceMaxFilter) params.max_price = priceMaxFilter;
const data = await api.listings.list(params);
setListings((data.listings || []).map(normalizeListing));
setTotal(data.total || 0);
} catch (err) {
console.error('Failed to load listings:', err);
} finally {
setLoading(false);
}
};
load();
}, [categorySlug, priceMinFilter, priceMaxFilter, searchFilter, sortBy, currentPage]);
const handleFilterChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFilters(prev => ({ ...prev, [name]: value }));
};
const applyFilters = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (filters.category) params.set('category', filters.category);
if (filters.priceMin) params.set('price_min', filters.priceMin);
if (filters.priceMax) params.set('price_max', filters.priceMax);
if (filters.search) params.set('search', filters.search);
params.set('sort', sortBy);
params.set('page', '1');
setSearchParams(params);
};
const clearFilters = () => {
setFilters({ category: '', priceMin: '', priceMax: '', search: '' });
setSearchParams(new URLSearchParams({ sort: sortBy, page: '1' }));
};
const totalPages = Math.ceil(total / PAGE_SIZE);
const goToPage = (page: number) => {
const p = new URLSearchParams(searchParams);
p.set('page', String(page));
setSearchParams(p);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const activeCategory = categories.find(c => c.slug === categorySlug);
return (
<div className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-2">{t.listings.browse}</h1>
{activeCategory && (
<p className="mb-4 text-gray-600">
{t.listings.categoryLabel} <span className="font-semibold">{activeCategory.name}</span>
</p>
)}
{/* Filters */}
<form onSubmit={applyFilters} className="mb-6 flex flex-wrap gap-3 items-end">
<div className="relative flex-grow min-w-[200px]">
<Search className="absolute left-3 top-2.5 text-gray-400" />
<input
name="search"
value={filters.search}
onChange={handleFilterChange}
placeholder={t.listings.searchPlaceholder}
className="w-full pl-10 pr-4 py-2 border rounded-md"
/>
</div>
<select
name="category"
value={filters.category}
onChange={handleFilterChange}
className="px-3 py-2 border rounded-md"
>
<option value="">{t.listings.allCategories}</option>
{categories.map(c => (
<option key={c.id} value={c.slug}>{c.name}</option>
))}
</select>
<input
type="number"
name="priceMin"
value={filters.priceMin}
onChange={handleFilterChange}
placeholder={t.listings.minPrice}
className="w-32 px-3 py-2 border rounded-md"
/>
<input
type="number"
name="priceMax"
value={filters.priceMax}
onChange={handleFilterChange}
placeholder={t.listings.maxPrice}
className="w-32 px-3 py-2 border rounded-md"
/>
<Button type="submit" variant="secondary" size="sm">{t.listings.apply}</Button>
<Button type="button" variant="outline" size="sm" onClick={clearFilters}>
{t.listings.clearFilters}
</Button>
</form>
{/* Sort */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-500">{total} {total !== 1 ? t.listings.listingsFound : t.listings.listingFound}</p>
<select
value={sortBy}
onChange={e => {
const p = new URLSearchParams(searchParams);
p.set('sort', e.target.value);
setSearchParams(p);
}}
className="px-3 py-1.5 border rounded-md text-sm"
>
<option value="newest">{t.listings.newest}</option>
<option value="oldest">{t.listings.oldest}</option>
<option value="price_low">{t.listings.priceLow}</option>
<option value="price_high">{t.listings.priceHigh}</option>
<option value="popular">{t.listings.popular}</option>
</select>
</div>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} className="h-64 bg-gray-200 rounded-lg animate-pulse" />
))}
</div>
) : listings.length > 0 ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{listings.map(listing => (
<ListingCard
key={listing.id}
listing={listing}
initialFav={user ? favIds.has(listing.id) : undefined}
/>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-8">
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
className="p-2 rounded-md border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
<ChevronLeft className="h-5 w-5" />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | '...')[]>((acc, p, i, arr) => {
if (i > 0 && p - (arr[i - 1] as number) > 1) acc.push('...');
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-gray-400"></span>
) : (
<button
key={p}
onClick={() => goToPage(p as number)}
className={`w-9 h-9 rounded-md border text-sm font-medium ${
currentPage === p
? 'bg-accent-600 text-white border-accent-600'
: 'border-gray-300 hover:bg-gray-50'
}`}
>
{p}
</button>
)
)}
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
className="p-2 rounded-md border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
)}
</>
) : (
<div className="text-center py-12">
<p className="mb-4 text-lg text-primary-600">{t.listings.noResults}</p>
<Button variant="outline" onClick={clearFilters}>{t.listings.clearFilters}</Button>
</div>
)}
</div>
);
};
export default Listings;

174
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,174 @@
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';
import { usePageTitle } from '../hooks/usePageTitle';
const Login: React.FC = () => {
usePageTitle('Sign In');
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-end">
<Link to="/forgot-password" className="text-sm font-medium text-accent-600 hover:text-accent-800">
Forgot your password?
</Link>
</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;

30
src/pages/NotFound.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Search } from 'lucide-react';
import Button from '../components/common/Button';
import { usePageTitle } from '../hooks/usePageTitle';
const NotFound: React.FC = () => {
usePageTitle('Page Not Found');
return (
<div className="max-w-2xl mx-auto px-4 py-24 text-center">
<p className="text-8xl font-extrabold text-accent-600 mb-4">404</p>
<h1 className="text-3xl font-bold text-primary-800 mb-3">Page Not Found</h1>
<p className="text-primary-500 mb-8">
The page you're looking for doesn't exist or has been moved.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link to="/">
<Button variant="secondary">Go to Home</Button>
</Link>
<Link to="/listings">
<Button variant="outline" icon={<Search className="h-4 w-4" />}>
Browse Listings
</Button>
</Link>
</div>
</div>
);
};
export default NotFound;

137
src/pages/PaymentReturn.tsx Normal file
View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import { api } from '../services/api';
import { Payment } from '../types';
import { useI18n } from '../contexts/I18nContext';
import { usePageTitle } from '../hooks/usePageTitle';
import Receipt from '../components/payment/Receipt';
import Button from '../components/common/Button';
const MAX_RETRIES = 5;
const RETRY_DELAY_MS = 2000;
const PaymentReturn: React.FC = () => {
const { t } = useI18n();
usePageTitle('Payment Return');
const [searchParams] = useSearchParams();
const transactionId = searchParams.get('transaction_id');
const [payment, setPayment] = useState<Payment | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (!transactionId) {
setError('Missing transaction ID');
return;
}
let cancelled = false;
const poll = async (retries: number) => {
try {
const data = await api.payments.get(transactionId) as Payment;
if (cancelled) return;
if (data.status === 'pending' && retries < MAX_RETRIES) {
setAttempt(retries + 1);
setTimeout(() => poll(retries + 1), RETRY_DELAY_MS);
} else {
setPayment(data);
}
} catch (err: any) {
if (!cancelled) {
setError(err.message || 'Failed to load payment');
}
}
};
poll(0);
return () => { cancelled = true; };
}, [transactionId]);
if (!transactionId) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<AlertCircle className="h-12 w-12 text-warning-500 mx-auto mb-4" />
<p className="text-primary-700">Invalid payment return URL.</p>
<Link to="/" className="mt-4 inline-block text-accent-600 hover:underline">
{t.payment.backToHome}
</Link>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<XCircle className="h-12 w-12 text-error-500 mx-auto mb-4" />
<p className="text-primary-700 mb-4">{error}</p>
<Link to="/">
<Button variant="outline">{t.payment.backToHome}</Button>
</Link>
</div>
</div>
);
}
if (!payment || payment.status === 'pending') {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<div className="w-10 h-10 border-4 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-primary-700">{t.payment.checkingStatus}</p>
{attempt > 0 && (
<p className="text-xs text-primary-400 mt-2">
Attempt {attempt}/{MAX_RETRIES}...
</p>
)}
</div>
</div>
);
}
if (payment.status === 'completed') {
return (
<div className="max-w-lg mx-auto px-4 py-12">
<div className="text-center mb-6">
<CheckCircle className="h-14 w-14 text-success-500 mx-auto mb-3" />
<h1 className="text-2xl font-bold text-primary-800">{t.payment.success}</h1>
</div>
<Receipt payment={payment} />
<div className="text-center mt-6">
<Link to="/" className="text-accent-600 hover:underline text-sm">
{t.payment.backToHome}
</Link>
</div>
</div>
);
}
// failed or cancelled
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="text-center">
<XCircle className="h-12 w-12 text-error-500 mx-auto mb-4" />
<h1 className="text-xl font-bold text-primary-800 mb-2">
{payment.status === 'cancelled' ? t.payment.cancelled : t.payment.failed}
</h1>
<p className="text-primary-600 mb-6 text-sm">Transaction: {payment.transaction_id}</p>
<div className="flex gap-3 justify-center">
<Link to="/subscription">
<Button variant="secondary">{t.payment.tryAgain}</Button>
</Link>
<Link to="/">
<Button variant="outline">{t.payment.backToHome}</Button>
</Link>
</div>
</div>
</div>
);
};
export default PaymentReturn;

344
src/pages/Profile.tsx Normal file
View File

@@ -0,0 +1,344 @@
import React, { useState, useEffect } from 'react';
import { User, Mail, Phone, Lock, Save, Heart, LogOut, Camera } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import Button from '../components/common/Button';
import Card, { CardContent, CardHeader } from '../components/common/Card';
import { useAuth } from '../contexts/AuthContext';
import { api } from '../services/api';
import { usePageTitle } from '../hooks/usePageTitle';
import { useToast } from '../contexts/ToastContext';
import { useI18n } from '../contexts/I18nContext';
const Profile: React.FC = () => {
const { t } = useI18n();
usePageTitle('My Account');
const { user, logout, refreshUser } = useAuth();
const { showToast } = useToast();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<'profile' | 'password'>('profile');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [error, setError] = useState('');
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(user?.avatarUrl);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
// Keep avatarUrl in sync when user context refreshes
useEffect(() => {
setAvatarUrl(user?.avatarUrl);
}, [user?.avatarUrl]);
const [profile, setProfile] = useState({
name: user?.name || '',
email: user?.email || '',
phone: user?.phone || '',
});
const [passwords, setPasswords] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: '',
});
// Fetch latest user data on mount (to get phone and any other fields)
useEffect(() => {
api.users.me()
.then((data: any) => {
setProfile({
name: data.name || '',
email: data.email || '',
phone: data.phone || '',
});
})
.catch(() => {});
}, []);
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setProfile((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploadingAvatar(true);
try {
const result = await api.uploads.image(file);
const url = result.url || result.urls?.[0];
if (url) {
await api.users.updateMe({ avatar_url: url });
await refreshUser();
setAvatarUrl(url);
showToast(t.profile.avatarUpdated, 'success');
}
} catch (err: any) {
showToast(err.message || 'Failed to upload avatar', 'error');
} finally {
setUploadingAvatar(false);
}
};
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswords((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setError('');
setSuccess('');
try {
await api.users.updateMe({ name: profile.name, email: profile.email, phone: profile.phone || undefined });
await refreshUser();
setSuccess(t.profile.profileUpdated);
showToast(t.profile.profileUpdated, 'success');
} catch (err: any) {
setError(err.message || 'Failed to update profile');
showToast(err.message || 'Failed to update profile', 'error');
} finally {
setIsLoading(false);
}
};
const handlePasswordSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setSuccess('');
if (passwords.newPassword !== passwords.confirmPassword) {
setError(t.profile.passwordsMismatch);
return;
}
if (passwords.newPassword.length < 8) {
setError(t.profile.passwordTooShort);
return;
}
setIsLoading(true);
try {
await api.auth.changePassword(passwords.currentPassword, passwords.newPassword);
setSuccess(t.profile.passwordChanged);
showToast(t.profile.passwordChanged, 'success');
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (err: any) {
setError(err.message || 'Failed to change password');
showToast(err.message || 'Failed to change password', 'error');
} finally {
setIsLoading(false);
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
if (!user) {
navigate('/login');
return null;
}
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<h1 className="text-3xl font-bold text-primary-800 mb-8">{t.profile.title}</h1>
{/* Profile Header */}
<Card className="mb-6">
<CardContent className="p-6">
<div className="flex items-center space-x-4">
<label className="relative cursor-pointer group">
<div className="w-20 h-20 rounded-full bg-primary-100 flex items-center justify-center overflow-hidden">
{avatarUrl ? (
<img src={avatarUrl} alt="Avatar" className="w-full h-full object-cover" />
) : (
<User className="h-10 w-10 text-primary-600" />
)}
</div>
<div className="absolute inset-0 rounded-full bg-black bg-opacity-0 group-hover:bg-opacity-30 flex items-center justify-center transition-all">
<Camera className="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
<input type="file" accept="image/*" className="hidden" onChange={handleAvatarUpload} disabled={uploadingAvatar} />
{uploadingAvatar && (
<div className="absolute inset-0 rounded-full bg-black bg-opacity-40 flex items-center justify-center">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</label>
<div>
<h2 className="text-xl font-semibold text-primary-800">{user.name}</h2>
<p className="text-primary-500">{user.email}</p>
<span className="inline-block mt-1 px-2 py-0.5 text-xs font-medium rounded-full bg-accent-100 text-accent-800 capitalize">
{user.role}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Tabs */}
<div className="flex space-x-1 border border-gray-200 rounded-lg p-1 bg-gray-50 w-fit mb-6">
<button
onClick={() => { setActiveTab('profile'); setError(''); setSuccess(''); }}
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
activeTab === 'profile'
? 'bg-white shadow-sm text-primary-800'
: 'text-primary-600 hover:text-primary-800'
}`}
>
<User className="h-4 w-4 mr-2" />
{t.profile.profileTab}
</button>
<button
onClick={() => { setActiveTab('password'); setError(''); setSuccess(''); }}
className={`px-4 py-2 text-sm font-medium rounded-md flex items-center ${
activeTab === 'password'
? 'bg-white shadow-sm text-primary-800'
: 'text-primary-600 hover:text-primary-800'
}`}
>
<Lock className="h-4 w-4 mr-2" />
{t.profile.passwordTab}
</button>
</div>
{/* Feedback */}
{success && (
<div className="mb-4 p-3 rounded-md bg-success-50 text-success-700 text-sm">{success}</div>
)}
{error && (
<div className="mb-4 p-3 rounded-md bg-error-50 text-error-700 text-sm">{error}</div>
)}
{/* Profile Form */}
{activeTab === 'profile' && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-primary-800">{t.profile.editProfile}</h3>
</CardHeader>
<CardContent>
<form onSubmit={handleProfileSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.fullName}</label>
<div className="relative">
<User className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
name="name"
value={profile.name}
onChange={handleProfileChange}
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.email}</label>
<div className="relative">
<Mail className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
name="email"
type="email"
value={profile.email}
onChange={handleProfileChange}
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.phone}</label>
<div className="relative">
<Phone className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
name="phone"
type="tel"
value={profile.phone}
onChange={handleProfileChange}
placeholder="+228 90 00 00 00"
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<Button type="submit" variant="secondary" disabled={isLoading} icon={<Save className="h-4 w-4" />}>
{isLoading ? t.profile.saving : t.profile.saveChanges}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Password Form */}
{activeTab === 'password' && (
<Card>
<CardHeader>
<h3 className="text-lg font-semibold text-primary-800">{t.profile.changePassword}</h3>
</CardHeader>
<CardContent>
<form onSubmit={handlePasswordSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.currentPassword}</label>
<input
name="currentPassword"
type="password"
value={passwords.currentPassword}
onChange={handlePasswordChange}
required
className="w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.newPassword}</label>
<input
name="newPassword"
type="password"
value={passwords.newPassword}
onChange={handlePasswordChange}
required
minLength={8}
className="w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-primary-700 mb-1">{t.profile.confirmPassword}</label>
<input
name="confirmPassword"
type="password"
value={passwords.confirmPassword}
onChange={handlePasswordChange}
required
minLength={8}
className="w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
<Button type="submit" variant="secondary" disabled={isLoading} icon={<Lock className="h-4 w-4" />}>
{isLoading ? t.profile.changing : t.profile.changePassword}
</Button>
</form>
</CardContent>
</Card>
)}
{/* Quick Links */}
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
<button
onClick={() => navigate('/favorites')}
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-gray-50 transition"
>
<Heart className="h-5 w-5 text-accent-600 mr-3" />
<span className="text-primary-700 font-medium">{t.profile.myFavorites}</span>
</button>
<button
onClick={handleLogout}
className="flex items-center p-4 border border-gray-200 rounded-lg hover:bg-error-50 transition"
>
<LogOut className="h-5 w-5 text-error-500 mr-3" />
<span className="text-error-600 font-medium">{t.profile.signOut}</span>
</button>
</div>
</div>
);
};
export default Profile;

207
src/pages/Register.tsx Normal file
View File

@@ -0,0 +1,207 @@
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { User, Mail, Lock, CheckCircle } from 'lucide-react';
import { useAuth } from '../contexts/AuthContext';
import { usePageTitle } from '../hooks/usePageTitle';
const Register: React.FC = () => {
usePageTitle('Create Account');
const { register } = useAuth();
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setError('');
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match!');
return;
}
if (formData.password.length < 8) {
setError('Password must be at least 8 characters long.');
return;
}
setIsLoading(true);
try {
await register({
email: formData.email,
password: formData.password,
name: formData.name,
role: 'agency',
});
setRegisteredEmail(formData.email);
} catch (err: any) {
setError(err.message || 'Registration failed. Please try again.');
} finally {
setIsLoading(false);
}
};
if (registeredEmail) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center items-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-6 shadow-lg rounded-lg text-center">
<CheckCircle className="h-16 w-16 text-success-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-primary-800 mb-2">Check your email</h2>
<p className="text-primary-600 mb-2">
We've sent a verification link to
</p>
<p className="font-semibold text-primary-800 mb-4">{registeredEmail}</p>
<p className="text-sm text-primary-500 mb-6">
Click the link in the email to verify your account and get started. Check your spam folder if you don't see it.
</p>
<Link to="/login" className="text-accent-600 hover:text-accent-800 text-sm font-medium">
Back to sign in
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center items-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Create a new account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link to="/login" className="font-medium text-accent-600 hover:text-accent-500">
sign in to your existing account
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow-lg sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Full Name
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-gray-400" />
</div>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
onChange={handleChange}
value={formData.name}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-accent-500 focus:border-accent-500 sm:text-sm"
placeholder="Your full name"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email address
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<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"
required
onChange={handleChange}
value={formData.email}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-accent-500 focus:border-accent-500 sm:text-sm"
placeholder="you@example.com"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<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="new-password"
required
onChange={handleChange}
value={formData.password}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-accent-500 focus:border-accent-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
Confirm Password
</label>
<div className="mt-1 relative rounded-md shadow-sm">
<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="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
onChange={handleChange}
value={formData.confirmPassword}
className="appearance-none block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:ring-accent-500 focus:border-accent-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">{error}</h3>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-accent-600 hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 disabled:opacity-60"
>
{isLoading ? 'Creating account...' : 'Create account'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default Register;

190
src/pages/ResetPassword.tsx Normal file
View File

@@ -0,0 +1,190 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Lock, CheckCircle } from 'lucide-react';
import Button from '../components/common/Button';
import { api } from '../services/api';
import { usePageTitle } from '../hooks/usePageTitle';
/**
* Supabase redirects here after a password-reset email click with the session in the URL hash:
* /reset-password#access_token=TOKEN&refresh_token=REFRESH&type=recovery
*
* We save the access_token so the API's Bearer header is set, then POST /password-reset/confirm
* with just { new_password } — the backend authenticates via get_current_user.
*/
function parseHash(): { accessToken: string; type: string } {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
return {
accessToken: params.get('access_token') ?? '',
type: params.get('type') ?? '',
};
}
const ResetPassword: React.FC = () => {
usePageTitle('Reset Password');
const navigate = useNavigate();
const [hasToken, setHasToken] = useState(false);
const [passwords, setPasswords] = useState({ newPassword: '', confirmPassword: '' });
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
const [countdown, setCountdown] = useState(3);
useEffect(() => {
const { accessToken, type } = parseHash();
if (accessToken && type === 'recovery') {
// Save temporarily so the request() helper uses it as Bearer
localStorage.setItem('access_token', accessToken);
// Clear hash from URL without reloading
window.history.replaceState(null, '', window.location.pathname);
setHasToken(true);
}
}, []);
useEffect(() => {
if (!isSuccess) return;
const timer = setInterval(() => {
setCountdown(n => {
if (n <= 1) {
clearInterval(timer);
navigate('/login');
return 0;
}
return n - 1;
});
}, 1000);
return () => clearInterval(timer);
}, [isSuccess, navigate]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPasswords(prev => ({ ...prev, [e.target.name]: e.target.value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!hasToken) {
setError('Invalid or missing reset token. Please request a new password reset link.');
return;
}
if (passwords.newPassword !== passwords.confirmPassword) {
setError('Passwords do not match.');
return;
}
if (passwords.newPassword.length < 8) {
setError('Password must be at least 8 characters.');
return;
}
setIsLoading(true);
try {
await api.auth.confirmPasswordReset(passwords.newPassword);
// Clear the recovery token — user must log in fresh
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setIsSuccess(true);
} catch (err: any) {
setError(err.message || 'Failed to reset password. The link may have expired.');
} finally {
setIsLoading(false);
}
};
if (isSuccess) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full text-center">
<div className="bg-white py-8 px-6 shadow-md rounded-lg">
<CheckCircle className="h-16 w-16 text-success-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-primary-800 mb-2">Password Reset!</h1>
<p className="text-primary-600 mb-6">
Your password has been successfully changed. Redirecting to sign in in {countdown}s
</p>
<Button variant="secondary" onClick={() => navigate('/login')}>
Sign In Now
</Button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4">
<div className="max-w-md w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary-800 mb-2">Reset Password</h1>
<p className="text-primary-600">Enter your new password below.</p>
</div>
<div className="bg-white py-8 px-6 shadow-md rounded-lg">
{error && (
<div className="mb-4 p-3 rounded-md bg-error-50 text-error-700 text-sm">{error}</div>
)}
{!hasToken && !error && (
<div className="mb-4 p-3 rounded-md bg-warning-50 text-warning-700 text-sm">
No reset token found. Please use the link from your email.
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-primary-700 mb-1">
New Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
id="newPassword"
name="newPassword"
type="password"
value={passwords.newPassword}
onChange={handleChange}
required
minLength={8}
placeholder="••••••••"
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-primary-700 mb-1">
Confirm New Password
</label>
<div className="relative">
<Lock className="absolute left-3 top-2.5 h-5 w-5 text-gray-400" />
<input
id="confirmPassword"
name="confirmPassword"
type="password"
value={passwords.confirmPassword}
onChange={handleChange}
required
minLength={8}
placeholder="••••••••"
className="pl-10 w-full border border-gray-300 rounded-md py-2 px-3 focus:outline-none focus:ring-2 focus:ring-accent-500"
/>
</div>
</div>
<Button type="submit" variant="secondary" fullWidth disabled={isLoading || !hasToken}>
{isLoading ? 'Resetting...' : 'Reset Password'}
</Button>
</form>
<div className="mt-6 text-center">
<Link to="/login" className="text-sm text-accent-600 hover:text-accent-800">
Back to Sign In
</Link>
</div>
</div>
</div>
</div>
);
};
export default ResetPassword;

View File

@@ -0,0 +1,145 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { CreditCard, CheckCircle, Clock } from 'lucide-react';
import { api } from '../services/api';
import { Subscription } from '../types';
import { useI18n } from '../contexts/I18nContext';
import { usePageTitle } from '../hooks/usePageTitle';
import { useToast } from '../contexts/ToastContext';
import Button from '../components/common/Button';
import Card, { CardContent, CardHeader } from '../components/common/Card';
interface SubscriptionStatus {
has_active_subscription: boolean;
subscription: Subscription | null;
}
const SubscriptionPage: React.FC = () => {
const { t } = useI18n();
usePageTitle('Subscription');
const { showToast } = useToast();
const [status, setStatus] = useState<SubscriptionStatus | null>(null);
const [loading, setLoading] = useState(true);
const [initiating, setInitiating] = useState<string | null>(null);
useEffect(() => {
api.subscriptions.me()
.then((data: any) => setStatus(data))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const handleSubscribe = async (plan: 'monthly' | 'yearly') => {
setInitiating(plan);
try {
const result = await api.payments.initiate({ type: 'subscription', plan }) as any;
window.location.href = result.payment_url;
} catch (err: any) {
showToast(err.message || 'Failed to initiate payment', 'error');
setInitiating(null);
}
};
const formatter = new Intl.NumberFormat('fr-TG', {
style: 'currency',
currency: 'XOF',
minimumFractionDigits: 0,
});
return (
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-10">
<div className="flex items-center gap-3 mb-8">
<CreditCard className="h-7 w-7 text-accent-600" />
<h1 className="text-3xl font-bold text-primary-800">{t.subscription.title}</h1>
</div>
{/* Current status */}
{loading ? (
<div className="mb-8 p-4 bg-gray-50 border border-gray-200 rounded-lg animate-pulse h-16" />
) : status?.has_active_subscription && status.subscription ? (
<div className="mb-8 p-4 bg-success-50 border border-success-200 rounded-lg flex items-start gap-3">
<CheckCircle className="h-5 w-5 text-success-600 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-success-800">
{t.subscription.activePlan}:{' '}
{status.subscription.plan === 'monthly' ? t.subscription.monthly : t.subscription.yearly}
</p>
<p className="text-sm text-success-700 mt-0.5">
{t.subscription.activeUntil}{' '}
{new Date(status.subscription.ends_at).toLocaleDateString('fr-TG')}
</p>
</div>
</div>
) : (
<div className="mb-8 p-4 bg-warning-50 border border-warning-200 rounded-lg flex items-start gap-3">
<Clock className="h-5 w-5 text-warning-600 mt-0.5 flex-shrink-0" />
<div>
<p className="font-medium text-warning-800">{t.subscription.noSubscription}</p>
<p className="text-sm text-warning-700 mt-0.5">{t.subscription.noSubscriptionDesc}</p>
</div>
</div>
)}
{/* Plan cards */}
<h2 className="text-xl font-semibold text-primary-800 mb-4">{t.subscription.choosePlan}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
{/* Monthly */}
<Card className="border-2 border-gray-200 hover:border-accent-400 transition-colors">
<CardHeader>
<h3 className="text-lg font-bold text-primary-800">{t.subscription.monthly}</h3>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold text-accent-700 mb-1">
{formatter.format(1000)}
</p>
<p className="text-sm text-primary-500 mb-4">{t.subscription.perMonth}</p>
<p className="text-sm text-primary-600 mb-6">{t.subscription.monthlyDesc}</p>
<Button
variant="secondary"
className="w-full"
disabled={initiating !== null}
onClick={() => handleSubscribe('monthly')}
>
{initiating === 'monthly' ? t.payment.processing : t.subscription.subscribe}
</Button>
</CardContent>
</Card>
{/* Yearly */}
<Card className="border-2 border-accent-400 relative">
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className="bg-accent-500 text-white text-xs font-bold px-3 py-1 rounded-full">
{t.subscription.saveMonths}
</span>
</div>
<CardHeader>
<h3 className="text-lg font-bold text-primary-800">{t.subscription.yearly}</h3>
</CardHeader>
<CardContent>
<p className="text-3xl font-bold text-accent-700 mb-1">
{formatter.format(10000)}
</p>
<p className="text-sm text-primary-500 mb-4">{t.subscription.perYear}</p>
<p className="text-sm text-primary-600 mb-6">{t.subscription.yearlyDesc}</p>
<Button
variant="secondary"
className="w-full"
disabled={initiating !== null}
onClick={() => handleSubscribe('yearly')}
>
{initiating === 'yearly' ? t.payment.processing : t.subscription.subscribe}
</Button>
</CardContent>
</Card>
</div>
<p className="mt-6 text-center text-sm text-primary-500">
<Link to="/agency/dashboard" className="text-accent-600 hover:underline">
Back to Dashboard
</Link>
</p>
</div>
);
};
export default SubscriptionPage;

96
src/pages/VerifyEmail.tsx Normal file
View File

@@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { CheckCircle, XCircle } from 'lucide-react';
import Button from '../components/common/Button';
import { usePageTitle } from '../hooks/usePageTitle';
import { useAuth } from '../contexts/AuthContext';
/**
* Supabase redirects here after email verification with the session in the URL hash:
* /verify-email#access_token=TOKEN&refresh_token=REFRESH&type=signup
*
* We extract the tokens, save them, then load the user profile.
*/
function parseHash(): { accessToken: string; refreshToken: string; type: string } {
const hash = window.location.hash.slice(1); // remove leading #
const params = new URLSearchParams(hash);
return {
accessToken: params.get('access_token') ?? '',
refreshToken: params.get('refresh_token') ?? '',
type: params.get('type') ?? '',
};
}
const VerifyEmail: React.FC = () => {
usePageTitle('Verify Email');
const { user, refreshUser } = useAuth();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [message, setMessage] = useState('');
useEffect(() => {
const { accessToken, refreshToken, type } = parseHash();
if (!accessToken || type !== 'signup') {
setStatus('error');
setMessage('No verification token found. Please use the link from your email.');
return;
}
// Save tokens so subsequent API calls are authenticated
localStorage.setItem('access_token', accessToken);
if (refreshToken) localStorage.setItem('refresh_token', refreshToken);
// Clear hash from URL without reloading
window.history.replaceState(null, '', window.location.pathname);
refreshUser()
.then(() => setStatus('success'))
.catch(() => {
// refreshUser failed, but tokens were saved — still treat as success
setStatus('success');
});
}, []);
const dashboardHref =
user?.role === 'admin'
? '/admin/dashboard'
: user?.role === 'agency'
? '/agency/dashboard'
: '/';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="max-w-md w-full bg-white rounded-xl shadow-lg p-8 text-center">
{status === 'loading' && (
<>
<div className="w-12 h-12 border-4 border-accent-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-primary-600">Verifying your email...</p>
</>
)}
{status === 'success' && (
<>
<CheckCircle className="h-16 w-16 text-success-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-primary-800 mb-2">Email Verified!</h1>
<p className="text-primary-600 mb-6">
Your email address has been verified. You can now use all features of the platform.
</p>
<Link to={dashboardHref}>
<Button variant="secondary" fullWidth>Go to Dashboard</Button>
</Link>
</>
)}
{status === 'error' && (
<>
<XCircle className="h-16 w-16 text-error-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-primary-800 mb-2">Verification Failed</h1>
<p className="text-primary-600 mb-6">{message}</p>
<Link to="/login">
<Button variant="outline" fullWidth>Back to Login</Button>
</Link>
</>
)}
</div>
</div>
);
};
export default VerifyEmail;

270
src/services/api.ts Normal file
View File

@@ -0,0 +1,270 @@
/**
* API service layer — replaces mock data with real API calls.
*
* Usage:
* 1. Set VITE_API_BASE_URL in your .env (default: http://localhost:8000)
* 2. Import { api } from '@/services/api'
* 3. Call api.listings.list(), api.auth.login(), etc.
*/
const BASE = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8000";
const API = `${BASE}/api/v1`;
// ── Token management ─────────────────────────────────────
function getAccessToken(): string | null {
return localStorage.getItem("access_token");
}
function getRefreshToken(): string | null {
return localStorage.getItem("refresh_token");
}
function saveTokens(access: string, refresh: string) {
localStorage.setItem("access_token", access);
localStorage.setItem("refresh_token", refresh);
}
function clearTokens() {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
}
// ── HTTP helpers ─────────────────────────────────────────
async function request<T = any>(
path: string,
options: RequestInit = {}
): Promise<T> {
const token = getAccessToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API}${path}`, { ...options, headers });
if (res.status === 401) {
// Try refresh
const refreshed = await tryRefresh();
if (refreshed) {
headers["Authorization"] = `Bearer ${getAccessToken()}`;
const retry = await fetch(`${API}${path}`, { ...options, headers });
if (!retry.ok) throw await parseError(retry);
return retry.json();
}
clearTokens();
window.location.href = "/login";
throw new Error("Session expired");
}
if (!res.ok) throw await parseError(res);
if (res.status === 204) return {} as T;
return res.json();
}
async function tryRefresh(): Promise<boolean> {
const refresh = getRefreshToken();
if (!refresh) return false;
try {
const res = await fetch(`${API}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refresh }),
});
if (!res.ok) return false;
const data = await res.json();
saveTokens(data.access_token, data.refresh_token);
return true;
} catch {
return false;
}
}
async function parseError(res: Response): Promise<Error> {
try {
const body = await res.json();
return new Error(body.detail || `Request failed (${res.status})`);
} catch {
return new Error(`Request failed (${res.status})`);
}
}
function uploadRequest(path: string, formData: FormData) {
const token = getAccessToken();
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
return fetch(`${API}${path}`, {
method: "POST",
headers,
body: formData,
}).then(async (res) => {
if (!res.ok) throw await parseError(res);
return res.json();
});
}
// ── Typed query builder ──────────────────────────────────
function qs(params: Record<string, any>): string {
const entries = Object.entries(params).filter(
([, v]) => v !== null && v !== undefined && v !== ""
);
if (!entries.length) return "";
return "?" + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString();
}
// ── API namespaces ───────────────────────────────────────
export const api = {
auth: {
register: (data: { email: string; password: string; name: string; role?: string }) =>
request("/auth/register", { method: "POST", body: JSON.stringify(data) }),
login: async (email: string, password: string) => {
const data = await request<any>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
saveTokens(data.access_token, data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
return data;
},
logout: () => {
clearTokens();
},
me: () => request("/auth/me"),
changePassword: (current_password: string, new_password: string) =>
request("/auth/change-password", {
method: "POST",
body: JSON.stringify({ current_password, new_password }),
}),
requestPasswordReset: (email: string) =>
request("/auth/password-reset/request", {
method: "POST",
body: JSON.stringify({ email }),
}),
confirmPasswordReset: (new_password: string) =>
request("/auth/password-reset/confirm", {
method: "POST",
body: JSON.stringify({ new_password }),
}),
resendVerification: () =>
request("/auth/resend-verification", { method: "POST" }),
},
users: {
me: () => request("/users/me"),
updateMe: (data: Record<string, any>) =>
request("/users/me", { method: "PATCH", body: JSON.stringify(data) }),
list: (params?: { page?: number; page_size?: number; role?: string }) =>
request(`/users${qs(params || {})}`),
get: (id: string) => request(`/users/${id}`),
verify: (id: string) => request(`/users/${id}/verify`, { method: "POST" }),
delete: (id: string) => request(`/users/${id}`, { method: "DELETE" }),
},
agencies: {
list: (params?: { page?: number; page_size?: number; verified_only?: boolean }) =>
request(`/agencies${qs(params || {})}`),
get: (id: string) => request(`/agencies/${id}`),
me: () => request("/agencies/me"),
create: (data: Record<string, any>) =>
request("/agencies", { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: Record<string, any>) =>
request(`/agencies/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
verify: (id: string) => request(`/agencies/${id}/verify`, { method: "POST" }),
revoke: (id: string) => request(`/agencies/${id}/revoke`, { method: "POST" }),
delete: (id: string) => request(`/agencies/${id}`, { method: "DELETE" }),
},
categories: {
list: () => request("/categories"),
get: (id: string) => request(`/categories/${id}`),
getBySlug: (slug: string) => request(`/categories/slug/${slug}`),
create: (data: Record<string, any>) =>
request("/categories", { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: Record<string, any>) =>
request(`/categories/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (id: string) => request(`/categories/${id}`, { method: "DELETE" }),
},
listings: {
list: (params?: Record<string, any>) => request(`/listings${qs(params || {})}`),
featured: () => request("/listings/featured"),
get: (id: string) => request(`/listings/${id}`),
mine: (params?: Record<string, any>) => request(`/listings/agency/mine${qs(params || {})}`),
create: (data: Record<string, any>) =>
request("/listings", { method: "POST", body: JSON.stringify(data) }),
update: (id: string, data: Record<string, any>) =>
request(`/listings/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
delete: (id: string) => request(`/listings/${id}`, { method: "DELETE" }),
adminAll: (params?: Record<string, any>) =>
request(`/listings/admin/all${qs(params || {})}`),
updateStatus: (id: string, status: string, rejection_reason?: string) =>
request(`/listings/${id}/status`, {
method: "PATCH",
body: JSON.stringify({ status, rejection_reason }),
}),
stats: (agency_id?: string) =>
request(`/listings/stats/overview${qs({ agency_id })}`),
},
messages: {
send: (data: { listing_id: string; name: string; email: string; phone?: string; message: string }) =>
request("/messages", { method: "POST", body: JSON.stringify(data) }),
list: (params?: { read?: boolean; page?: number; page_size?: number }) =>
request(`/messages${qs(params || {})}`),
unreadCount: () => request("/messages/unread-count"),
markRead: (id: string, read: boolean = true) =>
request(`/messages/${id}/read`, { method: "PATCH", body: JSON.stringify({ read }) }),
delete: (id: string) => request(`/messages/${id}`, { method: "DELETE" }),
},
favorites: {
add: (listing_id: string) =>
request("/favorites", { method: "POST", body: JSON.stringify({ listing_id }) }),
remove: (listing_id: string) =>
request(`/favorites/${listing_id}`, { method: "DELETE" }),
list: () => request("/favorites"),
check: (listing_id: string) => request(`/favorites/check/${listing_id}`),
},
uploads: {
image: (file: File) => {
const fd = new FormData();
fd.append("file", file);
return uploadRequest("/uploads/image", fd);
},
images: (files: File[]) => {
const fd = new FormData();
files.forEach((f) => fd.append("files", f));
return uploadRequest("/uploads/images", fd);
},
delete: (url: string) =>
request(`/uploads?url=${encodeURIComponent(url)}`, { method: "DELETE" }),
},
payments: {
initiate: (data: { type: string; plan?: string; listing_id?: string }) =>
request("/payments/initiate", { method: "POST", body: JSON.stringify(data) }),
get: (transactionId: string) => request(`/payments/${transactionId}`),
list: () => request("/payments/"),
},
subscriptions: {
me: () => request("/subscriptions/me"),
},
};
export default api;

100
src/types/index.ts Normal file
View File

@@ -0,0 +1,100 @@
export type UserRole = 'admin' | 'agency' | 'visitor';
export interface User {
id: string;
email: string;
name: string;
role: UserRole;
verified: boolean;
phone?: string;
avatarUrl?: string;
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;
listingCount?: number;
}
export type ListingStatus = 'pending' | 'approved' | 'rejected' | 'sold';
export interface Payment {
id: string;
transaction_id: string;
type: 'subscription' | 'purchase';
payer_id?: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed' | 'cancelled';
payment_method?: string;
operator_id?: string;
metadata?: Record<string, any>;
created_at: string;
paid_at?: string;
// Receipt enrichment
payer_name?: string;
payer_email?: string;
plan_label?: string;
listing_title?: string;
}
export interface Subscription {
id: string;
agency_id: string;
plan: 'monthly' | 'yearly';
status: 'active' | 'expired';
starts_at: string;
ends_at: string;
payment_id?: string;
created_at: string;
}
export interface Listing {
id: string;
title: string;
description: string;
price: number;
images: string[];
status: ListingStatus;
agencyId: string;
categoryId: string;
location: string;
listingType?: string;
condition?: string;
negotiable?: boolean;
viewsCount?: number;
rejectionReason?: string;
agencyName?: string;
categoryName?: string;
createdAt: string;
updatedAt?: string;
}
export interface Message {
id: string;
listingId: string;
name: string;
email: string;
phone?: string;
message: string;
agencyId: string;
createdAt: string;
read: boolean;
}

1
src/vite-env.d.ts vendored Normal file
View File

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

69
tailwind.config.js Normal file
View File

@@ -0,0 +1,69 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
},
colors: {
primary: {
50: '#F8FAFC',
100: '#F1F5F9',
200: '#E2E8F0',
300: '#CBD5E1',
400: '#94A3B8',
500: '#64748B',
600: '#475569',
700: '#334155',
800: '#1E293B',
900: '#0F172A',
950: '#020617',
},
accent: {
50: '#F0FDFA',
100: '#CCFBF1',
200: '#99F6E4',
300: '#5EEAD4',
400: '#2DD4BF',
500: '#14B8A6',
600: '#0D9488',
700: '#0F766E',
800: '#115E59',
900: '#134E4A',
950: '#042F2E',
},
success: {
50: '#F0FDF4',
500: '#22C55E',
700: '#15803D',
},
warning: {
50: '#FFFBEB',
500: '#F59E0B',
700: '#B45309',
},
error: {
50: '#FEF2F2',
500: '#EF4444',
700: '#B91C1C',
},
},
animation: {
'fade-in': 'fadeIn 0.3s ease-in-out',
'slide-up': 'slideUp 0.3s ease-in-out',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

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

22
tsconfig.node.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

14
vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
},
optimizeDeps: {
exclude: ['lucide-react'],
},
});