mirror of
http://88.130.71.182:3000/BlitTech/deals24togo_fe.git
synced 2026-06-12 23:33:21 +00:00
Heavy modifications. INtegrations of APIs
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal 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
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# sb1-RealEstate
|
||||||
|
|
||||||
|
[Edit in StackBlitz next generation editor ⚡️](https://stackblitz.com/~/github.com/Dosseh91/sb1-RealEstate)
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>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
4125
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "agency-listings-marketplace",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.22.3",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.9.1",
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^9.9.1",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.11",
|
||||||
|
"globals": "^15.9.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"typescript-eslint": "^8.3.0",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
193
src/App.tsx
Normal file
193
src/App.tsx
Normal 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;
|
||||||
323
src/components/admin/AgencyManagement.tsx
Normal file
323
src/components/admin/AgencyManagement.tsx
Normal 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;
|
||||||
260
src/components/admin/CategoryManagement.tsx
Normal file
260
src/components/admin/CategoryManagement.tsx
Normal 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;
|
||||||
189
src/components/admin/ListingReviewCard.tsx
Normal file
189
src/components/admin/ListingReviewCard.tsx
Normal 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;
|
||||||
174
src/components/admin/UserManagement.tsx
Normal file
174
src/components/admin/UserManagement.tsx
Normal 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;
|
||||||
519
src/components/agency/ListingForm.tsx
Normal file
519
src/components/agency/ListingForm.tsx
Normal 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;
|
||||||
229
src/components/agency/MessageList.tsx
Normal file
229
src/components/agency/MessageList.tsx
Normal 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;
|
||||||
26
src/components/common/BackToTop.tsx
Normal file
26
src/components/common/BackToTop.tsx
Normal 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;
|
||||||
63
src/components/common/Button.tsx
Normal file
63
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'success' | 'warning' | 'error' | 'link';
|
||||||
|
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: ButtonVariant;
|
||||||
|
size?: ButtonSize;
|
||||||
|
fullWidth?: boolean;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
fullWidth = false,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
disabled = false,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Base classes
|
||||||
|
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||||
|
|
||||||
|
// Size classes
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-base',
|
||||||
|
lg: 'px-6 py-3 text-lg',
|
||||||
|
}[size];
|
||||||
|
|
||||||
|
// Variant classes
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary-700 text-white hover:bg-primary-800 focus:ring-primary-700 active:bg-primary-900 disabled:bg-primary-300',
|
||||||
|
secondary: 'bg-accent-600 text-white hover:bg-accent-700 focus:ring-accent-600 active:bg-accent-800 disabled:bg-accent-300',
|
||||||
|
outline: 'border border-primary-300 text-primary-700 hover:bg-primary-50 focus:ring-primary-500 active:bg-primary-100 disabled:text-primary-300',
|
||||||
|
success: 'bg-success-500 text-white hover:bg-success-600 focus:ring-success-500 active:bg-success-700 disabled:bg-success-300',
|
||||||
|
warning: 'bg-warning-500 text-white hover:bg-warning-600 focus:ring-warning-500 active:bg-warning-700 disabled:bg-warning-300',
|
||||||
|
error: 'bg-error-500 text-white hover:bg-error-600 focus:ring-error-500 active:bg-error-700 disabled:bg-error-300',
|
||||||
|
link: 'text-accent-600 hover:text-accent-800 hover:underline focus:ring-accent-500 p-0 disabled:text-accent-300',
|
||||||
|
}[variant];
|
||||||
|
|
||||||
|
// Full width class
|
||||||
|
const widthClass = fullWidth ? 'w-full' : '';
|
||||||
|
|
||||||
|
// Disabled class
|
||||||
|
const disabledClass = disabled ? 'cursor-not-allowed opacity-70' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${baseClasses} ${sizeClasses} ${variantClasses} ${widthClass} ${disabledClass} ${className}`}
|
||||||
|
disabled={disabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className="mr-2">{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
57
src/components/common/Card.tsx
Normal file
57
src/components/common/Card.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
hoverable?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card: React.FC<CardProps> = ({ children, className = '', hoverable = false, onClick }) => {
|
||||||
|
const baseClasses = 'bg-white rounded-lg shadow-sm overflow-hidden border border-gray-200 transition-all duration-200';
|
||||||
|
const hoverClasses = hoverable ? 'hover:shadow-md hover:border-gray-300 cursor-pointer transform hover:-translate-y-1' : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${baseClasses} ${hoverClasses} ${className}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardHeader: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardContent: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`p-6 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardFooter: React.FC<{ children: React.ReactNode; className?: string }> = ({
|
||||||
|
children,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
58
src/components/common/ErrorBoundary.tsx
Normal file
58
src/components/common/ErrorBoundary.tsx
Normal 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;
|
||||||
130
src/components/common/Footer.tsx
Normal file
130
src/components/common/Footer.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Facebook, Twitter, Instagram, Linkedin, Mail, Phone } from 'lucide-react';
|
||||||
|
|
||||||
|
const Footer: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<footer className="bg-primary-900 text-white">
|
||||||
|
<div className="max-w-7xl mx-auto py-12 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
{/* Company info */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-xl font-bold">AgencyListings</h3>
|
||||||
|
<p className="text-primary-300 text-sm">
|
||||||
|
The premier marketplace for agency listings. Connecting professional agencies with interested clients.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
<Facebook className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
<Twitter className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
<Instagram className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
<a href="#" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
<Linkedin className="h-5 w-5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link to="/" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Home
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/listings" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Listings
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/categories" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Categories
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/about" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
About Us
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* More links */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">For Agencies</h3>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
<li>
|
||||||
|
<Link to="/register" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Join as Agency
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/pricing" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Pricing
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/resources" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
Resources
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/faq" className="text-primary-300 hover:text-white transition-colors">
|
||||||
|
FAQ
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact info */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Contact Us</h3>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
<li className="flex items-start">
|
||||||
|
<Mail className="h-5 w-5 mr-2 text-primary-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-primary-300">support@agencylistings.example.com</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex items-start">
|
||||||
|
<Phone className="h-5 w-5 mr-2 text-primary-400 flex-shrink-0 mt-0.5" />
|
||||||
|
<span className="text-primary-300">+1 (555) 123-4567</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-6">
|
||||||
|
<form className="space-y-3">
|
||||||
|
<h4 className="text-sm font-medium">Subscribe to our Newsletter</h4>
|
||||||
|
<div className="flex">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Your email"
|
||||||
|
className="px-3 py-2 text-sm text-gray-900 bg-white border border-r-0 border-gray-300 rounded-l-md w-full focus:outline-none focus:ring-1 focus:ring-accent-500"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="bg-accent-600 px-4 py-2 text-white text-sm font-medium rounded-r-md hover:bg-accent-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-500 focus:ring-offset-primary-900"
|
||||||
|
>
|
||||||
|
Subscribe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 pt-8 border-t border-primary-700">
|
||||||
|
<p className="text-center text-primary-400 text-sm">
|
||||||
|
© {new Date().getFullYear()} AgencyListings. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
314
src/components/common/Navbar.tsx
Normal file
314
src/components/common/Navbar.tsx
Normal 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;
|
||||||
33
src/components/common/StatusBadge.tsx
Normal file
33
src/components/common/StatusBadge.tsx
Normal 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;
|
||||||
195
src/components/listings/ContactForm.tsx
Normal file
195
src/components/listings/ContactForm.tsx
Normal 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;
|
||||||
142
src/components/listings/ListingCard.tsx
Normal file
142
src/components/listings/ListingCard.tsx
Normal 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;
|
||||||
116
src/components/payment/Receipt.tsx
Normal file
116
src/components/payment/Receipt.tsx
Normal 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;
|
||||||
117
src/contexts/AuthContext.tsx
Normal file
117
src/contexts/AuthContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
36
src/contexts/I18nContext.tsx
Normal file
36
src/contexts/I18nContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
122
src/contexts/ListingContext.tsx
Normal file
122
src/contexts/ListingContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
56
src/contexts/ToastContext.tsx
Normal file
56
src/contexts/ToastContext.tsx
Normal 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
288
src/data/mockData.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { User, Agency, Category, Listing, Message } from '../types';
|
||||||
|
|
||||||
|
export const users: User[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
name: 'Admin User',
|
||||||
|
role: 'admin',
|
||||||
|
verified: true,
|
||||||
|
createdAt: '2023-01-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
email: 'agency1@example.com',
|
||||||
|
name: 'Luxury Homes Agency',
|
||||||
|
role: 'agency',
|
||||||
|
verified: true,
|
||||||
|
createdAt: '2023-01-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
email: 'agency2@example.com',
|
||||||
|
name: 'CarSell Professional',
|
||||||
|
role: 'agency',
|
||||||
|
verified: true,
|
||||||
|
createdAt: '2023-01-03T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
email: 'agency3@example.com',
|
||||||
|
name: 'Tech Gear Pro',
|
||||||
|
role: 'agency',
|
||||||
|
verified: false,
|
||||||
|
createdAt: '2023-01-04T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const agencies: Agency[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
userId: '2',
|
||||||
|
name: 'Luxury Homes Agency',
|
||||||
|
description: 'We specialize in high-end real estate properties for discerning clients.',
|
||||||
|
logo: 'https://images.pexels.com/photos/106399/pexels-photo-106399.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
address: '123 Luxury Lane, Beverly Hills, CA 90210',
|
||||||
|
phone: '+1 (123) 456-7890',
|
||||||
|
email: 'info@luxuryhomes.example.com',
|
||||||
|
website: 'https://luxuryhomes.example.com',
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
userId: '3',
|
||||||
|
name: 'CarSell Professional',
|
||||||
|
description: 'Premier automotive sales agency with a wide selection of vehicles.',
|
||||||
|
logo: 'https://images.pexels.com/photos/120049/pexels-photo-120049.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
address: '456 Auto Drive, Detroit, MI 48226',
|
||||||
|
phone: '+1 (234) 567-8901',
|
||||||
|
email: 'sales@carsell.example.com',
|
||||||
|
website: 'https://carsell.example.com',
|
||||||
|
verified: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
userId: '4',
|
||||||
|
name: 'Tech Gear Pro',
|
||||||
|
description: 'Latest technology gadgets and electronics from trusted brands.',
|
||||||
|
logo: 'https://images.pexels.com/photos/1337753/pexels-photo-1337753.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
address: '789 Tech Ave, San Francisco, CA 94107',
|
||||||
|
phone: '+1 (345) 678-9012',
|
||||||
|
email: 'sales@techgear.example.com',
|
||||||
|
website: 'https://techgear.example.com',
|
||||||
|
verified: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const categories: Category[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'Real Estate',
|
||||||
|
description: 'Homes, apartments, land, and commercial properties',
|
||||||
|
icon: 'home',
|
||||||
|
slug: 'real-estate',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'Vehicles',
|
||||||
|
description: 'Cars, motorcycles, boats, and other vehicles',
|
||||||
|
icon: 'car',
|
||||||
|
slug: 'vehicles',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
name: 'Electronics',
|
||||||
|
description: 'Computers, phones, TVs, and other electronic devices',
|
||||||
|
icon: 'smartphone',
|
||||||
|
slug: 'electronics',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
name: 'Furniture',
|
||||||
|
description: 'Home and office furniture, decor, and appliances',
|
||||||
|
icon: 'sofa',
|
||||||
|
slug: 'furniture',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
name: 'Jobs',
|
||||||
|
description: 'Job listings and career opportunities',
|
||||||
|
icon: 'briefcase',
|
||||||
|
slug: 'jobs',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
name: 'Services',
|
||||||
|
description: 'Professional services and skilled trades',
|
||||||
|
icon: 'wrench',
|
||||||
|
slug: 'services',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const listings: Listing[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Luxury Penthouse with Ocean View',
|
||||||
|
description: 'Beautiful 3-bedroom penthouse with panoramic ocean views, featuring high-end finishes, a gourmet kitchen, and private rooftop terrace.',
|
||||||
|
price: 1500000,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/1396132/pexels-photo-1396132.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
'https://images.pexels.com/photos/1457847/pexels-photo-1457847.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'approved',
|
||||||
|
agencyId: '1',
|
||||||
|
categoryId: '1',
|
||||||
|
location: 'Miami, FL',
|
||||||
|
createdAt: '2023-02-01T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-02T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'Modern Downtown Loft',
|
||||||
|
description: 'Spacious industrial-style loft in the heart of downtown, featuring exposed brick walls, high ceilings, and modern amenities.',
|
||||||
|
price: 850000,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/1643383/pexels-photo-1643383.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
'https://images.pexels.com/photos/1571470/pexels-photo-1571470.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'approved',
|
||||||
|
agencyId: '1',
|
||||||
|
categoryId: '1',
|
||||||
|
location: 'New York, NY',
|
||||||
|
createdAt: '2023-02-03T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-04T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: '2023 Mercedes-Benz S-Class',
|
||||||
|
description: 'Brand new 2023 Mercedes-Benz S-Class with all available luxury features and extended warranty.',
|
||||||
|
price: 120000,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/120049/pexels-photo-120049.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
'https://images.pexels.com/photos/2365572/pexels-photo-2365572.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'approved',
|
||||||
|
agencyId: '2',
|
||||||
|
categoryId: '2',
|
||||||
|
location: 'Los Angeles, CA',
|
||||||
|
createdAt: '2023-02-05T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-06T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
title: 'Tesla Model Y Performance',
|
||||||
|
description: 'Like-new Tesla Model Y Performance with full self-driving capability, premium interior, and all available upgrades.',
|
||||||
|
price: 65000,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/13861/IMG_3496bfree.jpg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
'https://images.pexels.com/photos/3729464/pexels-photo-3729464.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'pending',
|
||||||
|
agencyId: '2',
|
||||||
|
categoryId: '2',
|
||||||
|
location: 'San Francisco, CA',
|
||||||
|
createdAt: '2023-02-07T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-08T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5',
|
||||||
|
title: 'Apple MacBook Pro 16" M2 Max',
|
||||||
|
description: 'Latest Apple MacBook Pro with M2 Max chip, 32GB RAM, 1TB SSD, and AppleCare+ coverage.',
|
||||||
|
price: 3499,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/303383/pexels-photo-303383.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'pending',
|
||||||
|
agencyId: '3',
|
||||||
|
categoryId: '3',
|
||||||
|
location: 'Online',
|
||||||
|
createdAt: '2023-02-09T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-10T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '6',
|
||||||
|
title: 'Samsung 85" Neo QLED 8K Smart TV',
|
||||||
|
description: 'Immersive viewing experience with Samsung\'s latest 8K television featuring AI upscaling and premium sound system.',
|
||||||
|
price: 5999,
|
||||||
|
images: [
|
||||||
|
'https://images.pexels.com/photos/6976103/pexels-photo-6976103.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1',
|
||||||
|
],
|
||||||
|
status: 'rejected',
|
||||||
|
agencyId: '3',
|
||||||
|
categoryId: '3',
|
||||||
|
location: 'Online',
|
||||||
|
createdAt: '2023-02-11T00:00:00Z',
|
||||||
|
updatedAt: '2023-02-12T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const messages: Message[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
listingId: '1',
|
||||||
|
name: 'John Smith',
|
||||||
|
email: 'john@example.com',
|
||||||
|
phone: '+1 (123) 456-7890',
|
||||||
|
message: 'I\'m interested in this property. Could I schedule a viewing for this weekend?',
|
||||||
|
agencyId: '1',
|
||||||
|
createdAt: '2023-03-01T00:00:00Z',
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
listingId: '3',
|
||||||
|
name: 'Sarah Johnson',
|
||||||
|
email: 'sarah@example.com',
|
||||||
|
phone: '+1 (234) 567-8901',
|
||||||
|
message: 'Is this car still available? I would like to see it in person and take it for a test drive.',
|
||||||
|
agencyId: '2',
|
||||||
|
createdAt: '2023-03-02T00:00:00Z',
|
||||||
|
read: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Helper functions to interact with mock data
|
||||||
|
export const generateId = () => uuidv4();
|
||||||
|
|
||||||
|
export const getCurrentTimestamp = () => new Date().toISOString();
|
||||||
|
|
||||||
|
// Auth related functions
|
||||||
|
export const findUserByEmail = (email: string) => {
|
||||||
|
return users.find(user => user.email === email);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findAgencyByUserId = (userId: string) => {
|
||||||
|
return agencies.find(agency => agency.userId === userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listing related functions
|
||||||
|
export const getListingsByAgencyId = (agencyId: string) => {
|
||||||
|
return listings.filter(listing => listing.agencyId === agencyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingById = (id: string) => {
|
||||||
|
return listings.find(listing => listing.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingsByStatus = (status: string) => {
|
||||||
|
return listings.filter(listing => listing.status === status);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getListingsByCategory = (categoryId: string) => {
|
||||||
|
return listings.filter(listing => listing.categoryId === categoryId);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category related functions
|
||||||
|
export const getCategoryById = (id: string) => {
|
||||||
|
return categories.find(category => category.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Message related functions
|
||||||
|
export const getMessagesByAgencyId = (agencyId: string) => {
|
||||||
|
return messages.filter(message => message.agencyId === agencyId);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMessagesByListingId = (listingId: string) => {
|
||||||
|
return messages.filter(message => message.listingId === listingId);
|
||||||
|
};
|
||||||
12
src/hooks/usePageTitle.ts
Normal file
12
src/hooks/usePageTitle.ts
Normal 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
2
src/i18n.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { useI18n, I18nProvider } from './contexts/I18nContext';
|
||||||
|
export type { Lang } from './i18n/translations';
|
||||||
581
src/i18n/translations.ts
Normal file
581
src/i18n/translations.ts
Normal 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
3
src/index.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
77
src/lib/normalizers.ts
Normal file
77
src/lib/normalizers.ts
Normal 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
11
src/main.tsx
Normal 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
81
src/pages/About.tsx
Normal 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;
|
||||||
335
src/pages/AdminDashboard.tsx
Normal file
335
src/pages/AdminDashboard.tsx
Normal 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;
|
||||||
401
src/pages/AgencyDashboard.tsx
Normal file
401
src/pages/AgencyDashboard.tsx
Normal 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;
|
||||||
175
src/pages/AgencyPublicProfile.tsx
Normal file
175
src/pages/AgencyPublicProfile.tsx
Normal 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
100
src/pages/Categories.tsx
Normal 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
108
src/pages/Favorites.tsx
Normal 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;
|
||||||
135
src/pages/ForgotPassword.tsx
Normal file
135
src/pages/ForgotPassword.tsx
Normal 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
259
src/pages/Home.tsx
Normal 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
369
src/pages/ListingDetail.tsx
Normal 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
273
src/pages/Listings.tsx
Normal 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
174
src/pages/Login.tsx
Normal 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
30
src/pages/NotFound.tsx
Normal 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
137
src/pages/PaymentReturn.tsx
Normal 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
344
src/pages/Profile.tsx
Normal 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
207
src/pages/Register.tsx
Normal 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
190
src/pages/ResetPassword.tsx
Normal 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;
|
||||||
145
src/pages/SubscriptionPage.tsx
Normal file
145
src/pages/SubscriptionPage.tsx
Normal 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
96
src/pages/VerifyEmail.tsx
Normal 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
270
src/services/api.ts
Normal 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
100
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
69
tailwind.config.js
Normal file
69
tailwind.config.js
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#F8FAFC',
|
||||||
|
100: '#F1F5F9',
|
||||||
|
200: '#E2E8F0',
|
||||||
|
300: '#CBD5E1',
|
||||||
|
400: '#94A3B8',
|
||||||
|
500: '#64748B',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1E293B',
|
||||||
|
900: '#0F172A',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
50: '#F0FDFA',
|
||||||
|
100: '#CCFBF1',
|
||||||
|
200: '#99F6E4',
|
||||||
|
300: '#5EEAD4',
|
||||||
|
400: '#2DD4BF',
|
||||||
|
500: '#14B8A6',
|
||||||
|
600: '#0D9488',
|
||||||
|
700: '#0F766E',
|
||||||
|
800: '#115E59',
|
||||||
|
900: '#134E4A',
|
||||||
|
950: '#042F2E',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
50: '#F0FDF4',
|
||||||
|
500: '#22C55E',
|
||||||
|
700: '#15803D',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#FFFBEB',
|
||||||
|
500: '#F59E0B',
|
||||||
|
700: '#B45309',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
50: '#FEF2F2',
|
||||||
|
500: '#EF4444',
|
||||||
|
700: '#B91C1C',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.3s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-in-out',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
14
vite.config.ts
Normal file
14
vite.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user