Add rental system: listings, bookings, payments, payouts, reviews
Full rental marketplace with 6 categories (apartment, house, car, motorcycle, bicycle, ebike). Booking workflow: create → confirm → pay → active → complete → payout. Landlord dashboard, admin moderation, availability calendar, Stripe Connect payouts. 14 QA bugs found and fixed including validator schemas, API response types, HTTP methods. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^3.1.0",
|
||||
"@stripe/stripe-js": "^5.0.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"socket.io-client": "^4.8.0",
|
||||
"@stripe/stripe-js": "^5.0.0",
|
||||
"@stripe/react-stripe-js": "^3.1.0",
|
||||
"lucide-react": "^0.469.0"
|
||||
"recharts": "^3.7.0",
|
||||
"socket.io-client": "^4.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
|
||||
97
client/src/components/ReportModal.tsx
Normal file
97
client/src/components/ReportModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal } from './ui/Modal';
|
||||
import { Button } from './ui/Button';
|
||||
import { GradientButton } from './ui/GradientButton';
|
||||
import { api } from '../api/client';
|
||||
import type { ReportReason } from '../types';
|
||||
|
||||
interface ReportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
targetType: 'LISTING' | 'USER';
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
const REASONS: { value: ReportReason; label: string }[] = [
|
||||
{ value: 'SPAM', label: 'Spam' },
|
||||
{ value: 'INAPPROPRIATE', label: 'Inappropriate content' },
|
||||
{ value: 'SCAM', label: 'Scam / Fraud' },
|
||||
{ value: 'COUNTERFEIT', label: 'Counterfeit item' },
|
||||
{ value: 'PROHIBITED_ITEM', label: 'Prohibited item' },
|
||||
{ value: 'HARASSMENT', label: 'Harassment' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
export function ReportModal({ isOpen, onClose, targetType, targetId }: ReportModalProps) {
|
||||
const [reason, setReason] = useState<ReportReason | ''>('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!reason) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
await api.post('/reports', { targetType, targetId, reason, description: description || undefined });
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
setSuccess(false);
|
||||
setReason('');
|
||||
setDescription('');
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit report');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Report" size="sm">
|
||||
{success ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-green-600 font-medium">Report submitted. Thank you!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && <p className="text-sm text-red-500 mb-3">{error}</p>}
|
||||
<p className="text-sm text-gray-500 mb-4">Why are you reporting this?</p>
|
||||
<div className="space-y-2 mb-4">
|
||||
{REASONS.map((r) => (
|
||||
<label key={r.value} className="flex items-center gap-3 p-2 rounded-lg hover:bg-gray-50 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="reason"
|
||||
value={r.value}
|
||||
checked={reason === r.value}
|
||||
onChange={() => setReason(r.value)}
|
||||
className="text-primary-600"
|
||||
/>
|
||||
<span className="text-sm">{r.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Additional details (optional)</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Provide more details..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={onClose}>Cancel</Button>
|
||||
<GradientButton className="flex-1" onClick={handleSubmit} disabled={!reason || submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit Report'}
|
||||
</GradientButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
51
client/src/components/layout/AdminLayout.tsx
Normal file
51
client/src/components/layout/AdminLayout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { LayoutDashboard, Users, ShoppingBag, Flag, Shield, CreditCard, Settings, Home, CalendarCheck, DollarSign } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/admin', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/admin/users', icon: Users, label: 'Users' },
|
||||
{ to: '/admin/listings', icon: ShoppingBag, label: 'Listings' },
|
||||
{ to: '/admin/reports', icon: Flag, label: 'Reports' },
|
||||
{ to: '/admin/moderation', icon: Shield, label: 'Moderation' },
|
||||
{ to: '/admin/payments', icon: CreditCard, label: 'Payments' },
|
||||
{ to: '/admin/rentals', icon: Home, label: 'Rentals' },
|
||||
{ to: '/admin/bookings', icon: CalendarCheck, label: 'Bookings' },
|
||||
{ to: '/admin/rental-payouts', icon: DollarSign, label: 'Rental Payouts' },
|
||||
{ to: '/admin/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
export function AdminLayout() {
|
||||
const { isAdmin } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-4rem)]">
|
||||
<aside className="w-64 bg-gray-900 text-white p-4 hidden md:block">
|
||||
<h2 className="text-lg font-bold mb-6 px-3">Admin Panel</h2>
|
||||
<nav className="space-y-1">
|
||||
{navItems.map(({ to, icon: Icon, label, end }) => {
|
||||
if ((label === 'Payments' || label === 'Settings') && !isAdmin) return null;
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-primary-600 text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 bg-gray-50 p-6 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { MessageSquare, Tag, ShoppingBag, Settings, List, Heart, LogOut } from 'lucide-react';
|
||||
import { MessageSquare, Tag, ShoppingBag, Settings, List, Heart, LogOut, CalendarCheck, Home } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
const navItems = [
|
||||
@@ -7,6 +7,8 @@ const navItems = [
|
||||
{ to: '/dashboard/sold', icon: ShoppingBag, label: 'Sold Items' },
|
||||
{ to: '/dashboard/listings', icon: List, label: 'My Listings' },
|
||||
{ to: '/dashboard/saved', icon: Heart, label: 'Saved Items' },
|
||||
{ to: '/dashboard/bookings', icon: CalendarCheck, label: 'My Bookings' },
|
||||
{ to: '/dashboard/saved-rentals', icon: Home, label: 'Saved Rentals' },
|
||||
{ to: '/dashboard/messages', icon: MessageSquare, label: 'My Messages' },
|
||||
{ to: '/dashboard/settings', icon: Settings, label: 'Settings' },
|
||||
];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Search, Bell, Menu, X, User, LogOut, ShoppingBag, Heart, MessageSquare, Settings } from 'lucide-react';
|
||||
import { Search, Bell, Menu, X, User, LogOut, ShoppingBag, Heart, MessageSquare, Settings, Shield, Home, Building2 } from 'lucide-react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
|
||||
export function Header() {
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const { user, isAuthenticated, isModerator, isLandlord, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
@@ -51,9 +51,20 @@ export function Header() {
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link to="/rentals" className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium text-white/80 hover:text-white transition-colors">
|
||||
<Home className="w-4 h-4 mr-1" /> Rentals
|
||||
</Link>
|
||||
<Link to="/sell" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-white/20 hover:bg-white/30 backdrop-blur transition-all">
|
||||
Sell Item
|
||||
</Link>
|
||||
<Link to="/rentals/new" className="hidden sm:inline-flex items-center px-4 py-2 text-sm font-semibold text-white rounded-xl bg-white/20 hover:bg-white/30 backdrop-blur transition-all">
|
||||
List a Rental
|
||||
</Link>
|
||||
{isModerator && (
|
||||
<Link to="/admin" className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium text-white/80 hover:text-white transition-colors">
|
||||
<Shield className="w-4 h-4 mr-1" /> Admin
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/dashboard/messages" className="p-2 rounded-lg hover:bg-white/10 transition-colors relative">
|
||||
<MessageSquare className="w-5 h-5 text-white/80" />
|
||||
</Link>
|
||||
@@ -82,6 +93,11 @@ export function Header() {
|
||||
<Link to="/dashboard/sold" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<Heart className="w-4 h-4" /> Sold Items
|
||||
</Link>
|
||||
{isLandlord && (
|
||||
<Link to="/landlord" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<Building2 className="w-4 h-4" /> Landlord Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/dashboard/settings" onClick={() => setShowUserMenu(false)} className="flex items-center gap-3 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<Settings className="w-4 h-4" /> Settings
|
||||
</Link>
|
||||
|
||||
48
client/src/components/layout/LandlordLayout.tsx
Normal file
48
client/src/components/layout/LandlordLayout.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NavLink, Outlet } from 'react-router-dom';
|
||||
import { LayoutDashboard, List, Calendar, CalendarDays, DollarSign, Star } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/landlord', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
{ to: '/landlord/listings', icon: List, label: 'My Rentals' },
|
||||
{ to: '/landlord/bookings', icon: Calendar, label: 'Bookings' },
|
||||
{ to: '/landlord/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/landlord/payouts', icon: DollarSign, label: 'Payouts' },
|
||||
{ to: '/landlord/reviews', icon: Star, label: 'Reviews' },
|
||||
];
|
||||
|
||||
export function LandlordLayout() {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden md:block w-56 flex-shrink-0">
|
||||
<nav className="bg-gradient-to-b from-violet-100 via-purple-50 to-pink-50 rounded-2xl p-3 sticky top-24 shadow-sm">
|
||||
<h2 className="text-sm font-bold text-violet-900 mb-3 px-4 pt-1">Landlord Portal</h2>
|
||||
{navItems.map(({ to, icon: Icon, label, end }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-white/70 text-violet-700 shadow-sm'
|
||||
: 'text-purple-700/70 hover:bg-white/40'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
client/src/components/layout/RequireRole.tsx
Normal file
21
client/src/components/layout/RequireRole.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
|
||||
interface RequireRoleProps {
|
||||
roles: string[];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function RequireRole({ roles, children }: RequireRoleProps) {
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center min-h-screen text-gray-500">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user?.role || !roles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
169
client/src/components/rentals/AvailabilityCalendar.tsx
Normal file
169
client/src/components/rentals/AvailabilityCalendar.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
interface DateRange {
|
||||
start: string;
|
||||
end: string;
|
||||
}
|
||||
|
||||
interface AvailabilityCalendarProps {
|
||||
blockedDates: DateRange[];
|
||||
bookedDates: DateRange[];
|
||||
}
|
||||
|
||||
type DayStatus = 'available' | 'booked' | 'blocked';
|
||||
|
||||
function isDateInRanges(date: Date, ranges: DateRange[]): boolean {
|
||||
const time = date.getTime();
|
||||
return ranges.some(range => {
|
||||
const start = new Date(range.start);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(range.end);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return time >= start.getTime() && time <= end.getTime();
|
||||
});
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
const DAY_LABELS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
export function AvailabilityCalendar({ blockedDates, bookedDates }: AvailabilityCalendarProps) {
|
||||
const today = new Date();
|
||||
const [viewYear, setViewYear] = useState(today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(today.getMonth());
|
||||
|
||||
const daysInMonth = getDaysInMonth(viewYear, viewMonth);
|
||||
const firstDay = getFirstDayOfMonth(viewYear, viewMonth);
|
||||
|
||||
const dayStatuses = useMemo(() => {
|
||||
const statuses: (DayStatus | null)[] = [];
|
||||
|
||||
// Leading empty cells
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
statuses.push(null);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const date = new Date(viewYear, viewMonth, day);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
if (isDateInRanges(date, bookedDates)) {
|
||||
statuses.push('booked');
|
||||
} else if (isDateInRanges(date, blockedDates)) {
|
||||
statuses.push('blocked');
|
||||
} else {
|
||||
statuses.push('available');
|
||||
}
|
||||
}
|
||||
|
||||
return statuses;
|
||||
}, [viewYear, viewMonth, firstDay, daysInMonth, blockedDates, bookedDates]);
|
||||
|
||||
const goToPrevMonth = () => {
|
||||
if (viewMonth === 0) {
|
||||
setViewMonth(11);
|
||||
setViewYear(y => y - 1);
|
||||
} else {
|
||||
setViewMonth(m => m - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
if (viewMonth === 11) {
|
||||
setViewMonth(0);
|
||||
setViewYear(y => y + 1);
|
||||
} else {
|
||||
setViewMonth(m => m + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const statusStyles: Record<DayStatus, string> = {
|
||||
available: 'bg-green-100 text-green-800',
|
||||
booked: 'bg-red-100 text-red-700',
|
||||
blocked: 'bg-gray-100 text-gray-400',
|
||||
};
|
||||
|
||||
const isCurrentMonth = viewYear === today.getFullYear() && viewMonth === today.getMonth();
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
onClick={goToPrevMonth}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold text-gray-900">
|
||||
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||
</h3>
|
||||
<button
|
||||
onClick={goToNextMonth}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day labels */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{DAY_LABELS.map(label => (
|
||||
<div key={label} className="text-center text-xs font-medium text-gray-400 py-1">
|
||||
{label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{dayStatuses.map((status, idx) => {
|
||||
if (status === null) {
|
||||
return <div key={`empty-${idx}`} />;
|
||||
}
|
||||
|
||||
const dayNumber = idx - firstDay + 1;
|
||||
const isToday = isCurrentMonth && dayNumber === today.getDate();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`text-center text-xs py-1.5 rounded-lg font-medium
|
||||
${statusStyles[status]}
|
||||
${isToday ? 'ring-2 ring-primary-400 ring-offset-1' : ''}`}
|
||||
>
|
||||
{dayNumber}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-gray-100">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-green-100 border border-green-200" />
|
||||
<span className="text-xs text-gray-500">Available</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-red-100 border border-red-200" />
|
||||
<span className="text-xs text-gray-500">Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-gray-100 border border-gray-200" />
|
||||
<span className="text-xs text-gray-500">Blocked</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
client/src/components/rentals/BookingForm.tsx
Normal file
176
client/src/components/rentals/BookingForm.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
import { GradientButton } from '../ui/GradientButton';
|
||||
import { api } from '../../api/client';
|
||||
import type { RentalListing, RentalPeriodType, Booking } from '../../types/rental';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
|
||||
interface BookingFormProps {
|
||||
rental: RentalListing;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function BookingForm({ rental, onSuccess }: BookingFormProps) {
|
||||
const hasDailyPrice = rental.dailyPrice != null;
|
||||
const hasMonthlyPrice = rental.monthlyPrice != null;
|
||||
|
||||
const defaultPeriod: RentalPeriodType = hasDailyPrice ? 'DAILY' : 'MONTHLY';
|
||||
|
||||
const [periodType, setPeriodType] = useState<RentalPeriodType>(defaultPeriod);
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const priceEstimate = useMemo(() => {
|
||||
if (!startDate || !endDate) return null;
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
if (end <= start) return null;
|
||||
|
||||
const diffMs = end.getTime() - start.getTime();
|
||||
|
||||
if (periodType === 'DAILY') {
|
||||
const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (days < 1 || rental.dailyPrice == null) return null;
|
||||
return { units: days, unitLabel: days === 1 ? 'day' : 'days', unitPrice: rental.dailyPrice, total: days * rental.dailyPrice };
|
||||
} else {
|
||||
const months = Math.max(1, Math.round(diffMs / (1000 * 60 * 60 * 24 * 30)));
|
||||
if (rental.monthlyPrice == null) return null;
|
||||
return { units: months, unitLabel: months === 1 ? 'month' : 'months', unitPrice: rental.monthlyPrice, total: months * rental.monthlyPrice };
|
||||
}
|
||||
}, [startDate, endDate, periodType, rental.dailyPrice, rental.monthlyPrice]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
setError('Please select both start and end dates');
|
||||
return;
|
||||
}
|
||||
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
if (end <= start) {
|
||||
setError('End date must be after start date');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post<Booking>('/bookings', {
|
||||
rentalListingId: rental.id,
|
||||
periodType,
|
||||
startDate: new Date(startDate).toISOString(),
|
||||
endDate: new Date(endDate).toISOString(),
|
||||
});
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit booking request');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<CalendarDays className="w-5 h-5 text-primary-500" />
|
||||
<h3 className="text-lg font-bold text-gray-900">Book this Rental</h3>
|
||||
</div>
|
||||
|
||||
{/* Period Type Selector */}
|
||||
{hasDailyPrice && hasMonthlyPrice && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Rental Period</label>
|
||||
<div className="flex rounded-xl border border-gray-200 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodType('DAILY')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer
|
||||
${periodType === 'DAILY'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
Daily ({formatCurrency(rental.dailyPrice!)}/day)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodType('MONTHLY')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer
|
||||
${periodType === 'MONTHLY'
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'}`}
|
||||
>
|
||||
Monthly ({formatCurrency(rental.monthlyPrice!)}/mo)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Date Pickers */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={e => setStartDate(e.target.value)}
|
||||
min={todayStr}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900
|
||||
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">End Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={e => setEndDate(e.target.value)}
|
||||
min={startDate || todayStr}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900
|
||||
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price Calculation */}
|
||||
{priceEstimate && (
|
||||
<div className="mb-4 bg-gray-50 rounded-xl p-4 border border-gray-100">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 mb-1">
|
||||
<span>
|
||||
{formatCurrency(priceEstimate.unitPrice)} x {priceEstimate.units} {priceEstimate.unitLabel}
|
||||
</span>
|
||||
<span>{formatCurrency(priceEstimate.total)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-200 mt-2">
|
||||
<span className="text-sm font-semibold text-gray-900">Estimated Total</span>
|
||||
<span className="text-lg font-bold text-primary-600">{formatCurrency(priceEstimate.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="mb-3 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<GradientButton
|
||||
type="submit"
|
||||
isLoading={submitting}
|
||||
disabled={submitting || !startDate || !endDate}
|
||||
className="w-full"
|
||||
>
|
||||
Request Booking
|
||||
</GradientButton>
|
||||
|
||||
<p className="mt-2 text-xs text-gray-400 text-center">
|
||||
You won't be charged until the landlord confirms your booking.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
25
client/src/components/rentals/BookingStatusBadge.tsx
Normal file
25
client/src/components/rentals/BookingStatusBadge.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Badge } from '../ui/Badge';
|
||||
import type { BookingStatus } from '../../types/rental';
|
||||
|
||||
interface BookingStatusBadgeProps {
|
||||
status: BookingStatus;
|
||||
}
|
||||
|
||||
const statusConfig: Record<BookingStatus, { variant: 'default' | 'success' | 'warning' | 'error' | 'info'; label: string }> = {
|
||||
PENDING: { variant: 'warning', label: 'Pending' },
|
||||
CONFIRMED: { variant: 'info', label: 'Confirmed' },
|
||||
ACTIVE: { variant: 'success', label: 'Active' },
|
||||
COMPLETED: { variant: 'success', label: 'Completed' },
|
||||
CANCELLED_BY_TENANT: { variant: 'error', label: 'Cancelled by Tenant' },
|
||||
CANCELLED_BY_LANDLORD: { variant: 'error', label: 'Cancelled by Landlord' },
|
||||
REJECTED: { variant: 'error', label: 'Rejected' },
|
||||
EXPIRED: { variant: 'default', label: 'Expired' },
|
||||
};
|
||||
|
||||
export function BookingStatusBadge({ status }: BookingStatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant}>{config.label}</Badge>
|
||||
);
|
||||
}
|
||||
45
client/src/components/rentals/PriceDisplay.tsx
Normal file
45
client/src/components/rentals/PriceDisplay.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
|
||||
interface PriceDisplayProps {
|
||||
dailyPrice?: number;
|
||||
monthlyPrice?: number;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export function PriceDisplay({ dailyPrice, monthlyPrice, size = 'md' }: PriceDisplayProps) {
|
||||
const textSizes = {
|
||||
sm: 'text-sm',
|
||||
md: 'text-lg',
|
||||
lg: 'text-2xl',
|
||||
};
|
||||
|
||||
const labelSizes = {
|
||||
sm: 'text-[10px]',
|
||||
md: 'text-xs',
|
||||
lg: 'text-sm',
|
||||
};
|
||||
|
||||
if (dailyPrice == null && monthlyPrice == null) {
|
||||
return <span className="text-gray-400 text-sm">Price not set</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline gap-3">
|
||||
{dailyPrice != null && (
|
||||
<p className={`${textSizes[size]} font-bold text-primary-600`}>
|
||||
{formatCurrency(dailyPrice)}
|
||||
<span className={`${labelSizes[size]} font-normal text-gray-400 ml-0.5`}>/day</span>
|
||||
</p>
|
||||
)}
|
||||
{dailyPrice != null && monthlyPrice != null && (
|
||||
<span className="text-gray-300">|</span>
|
||||
)}
|
||||
{monthlyPrice != null && (
|
||||
<p className={`${textSizes[size]} font-bold text-primary-600`}>
|
||||
{formatCurrency(monthlyPrice)}
|
||||
<span className={`${labelSizes[size]} font-normal text-gray-400 ml-0.5`}>/month</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
client/src/components/rentals/RentalCard.tsx
Normal file
110
client/src/components/rentals/RentalCard.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Heart, MapPin, Star } from 'lucide-react';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import type { RentalListing } from '../../types/rental';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
|
||||
interface RentalCardProps {
|
||||
rental: RentalListing;
|
||||
}
|
||||
|
||||
const categoryEmojis: Record<string, string> = {
|
||||
APARTMENT: '\u{1F3E0}',
|
||||
HOUSE: '\u{1F3E1}',
|
||||
CAR: '\u{1F697}',
|
||||
MOTORCYCLE: '\u{1F3CD}',
|
||||
BICYCLE: '\u{1F6B2}',
|
||||
EBIKE: '\u26A1',
|
||||
OTHER: '\u{1F4E6}',
|
||||
};
|
||||
|
||||
export function RentalCard({ rental }: RentalCardProps) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [isFav, setIsFav] = useState(rental.isFavorited ?? false);
|
||||
const [toggling, setToggling] = useState(false);
|
||||
|
||||
const emoji = categoryEmojis[rental.category] ?? '\u{1F4E6}';
|
||||
|
||||
const handleFavorite = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isAuthenticated || toggling) return;
|
||||
setToggling(true);
|
||||
try {
|
||||
const res = await api.post<{ isFavorited: boolean }>(`/rentals/${rental.id}/favorite`);
|
||||
setIsFav(res.isFavorited);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
setToggling(false);
|
||||
};
|
||||
|
||||
const hasImage = rental.images?.[0]?.url;
|
||||
|
||||
return (
|
||||
<Link to={`/rentals/${rental.id}`} className="group block">
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200">
|
||||
{/* Image */}
|
||||
<div className="relative aspect-square bg-gray-100">
|
||||
{hasImage ? (
|
||||
<img src={rental.images[0].url} alt={rental.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary-50 to-pink-50">
|
||||
<span className="text-5xl">{emoji}</span>
|
||||
</div>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={handleFavorite}
|
||||
className="absolute top-2 right-2 p-1.5 bg-white/80 backdrop-blur rounded-full hover:bg-white transition-colors cursor-pointer"
|
||||
>
|
||||
<Heart className={`w-4 h-4 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 truncate group-hover:text-primary-600 transition-colors">
|
||||
{rental.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-baseline gap-1.5 mt-1">
|
||||
{rental.dailyPrice != null && (
|
||||
<p className="text-lg font-bold text-primary-600">
|
||||
{formatCurrency(rental.dailyPrice)}
|
||||
<span className="text-xs font-normal text-gray-400">/day</span>
|
||||
</p>
|
||||
)}
|
||||
{rental.monthlyPrice != null && (
|
||||
<p className={`font-bold text-primary-600 ${rental.dailyPrice != null ? 'text-sm' : 'text-lg'}`}>
|
||||
{rental.dailyPrice != null && <span className="text-gray-300 font-normal mx-1">·</span>}
|
||||
{formatCurrency(rental.monthlyPrice)}
|
||||
<span className="text-xs font-normal text-gray-400">/mo</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<Badge variant="info">{rental.category.replace('_', ' ')}</Badge>
|
||||
<span className="flex items-center gap-1 text-xs text-gray-400">
|
||||
<MapPin className="w-3 h-3" />
|
||||
{rental.location}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rental.avgRating != null && rental._count?.reviews != null && rental._count.reviews > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-gray-500">
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400" />
|
||||
<span className="font-medium text-gray-700">{rental.avgRating.toFixed(1)}</span>
|
||||
<span>({rental._count.reviews})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
47
client/src/components/rentals/RentalCategorySidebar.tsx
Normal file
47
client/src/components/rentals/RentalCategorySidebar.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { RentalCategory } from '../../types/rental';
|
||||
|
||||
interface CategoryItem {
|
||||
value: RentalCategory;
|
||||
label: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const categories: CategoryItem[] = [
|
||||
{ value: 'APARTMENT', label: 'Apartments', emoji: '\u{1F3E0}' },
|
||||
{ value: 'HOUSE', label: 'Houses', emoji: '\u{1F3E1}' },
|
||||
{ value: 'CAR', label: 'Cars', emoji: '\u{1F697}' },
|
||||
{ value: 'MOTORCYCLE', label: 'Motorcycles', emoji: '\u{1F3CD}' },
|
||||
{ value: 'BICYCLE', label: 'Bicycles', emoji: '\u{1F6B2}' },
|
||||
{ value: 'EBIKE', label: 'E-Bikes', emoji: '\u26A1' },
|
||||
];
|
||||
|
||||
interface RentalCategorySidebarProps {
|
||||
selected: string | null;
|
||||
onSelect: (category: string | null) => void;
|
||||
}
|
||||
|
||||
export function RentalCategorySidebar({ selected, onSelect }: RentalCategorySidebarProps) {
|
||||
return (
|
||||
<nav className="bg-gradient-to-b from-purple-50 to-pink-50/50 rounded-2xl p-3">
|
||||
<button
|
||||
onClick={() => onSelect(null)}
|
||||
className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer
|
||||
${!selected ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}
|
||||
>
|
||||
<span className="text-base">{'\u{1F3E0}'}</span>
|
||||
All Rentals
|
||||
</button>
|
||||
{categories.map(({ value, label, emoji }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => onSelect(value)}
|
||||
className={`flex items-center gap-3 w-full px-3 py-2.5 rounded-xl text-sm font-medium transition-colors cursor-pointer
|
||||
${selected === value ? 'bg-white/70 text-primary-700 shadow-sm' : 'text-purple-700/70 hover:bg-white/40'}`}
|
||||
>
|
||||
<span className="text-base">{emoji}</span>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
24
client/src/components/rentals/RentalGrid.tsx
Normal file
24
client/src/components/rentals/RentalGrid.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { RentalCard } from './RentalCard';
|
||||
import type { RentalListing } from '../../types/rental';
|
||||
|
||||
interface RentalGridProps {
|
||||
rentals: RentalListing[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function RentalGrid({ rentals, title }: RentalGridProps) {
|
||||
return (
|
||||
<section>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-gray-900">{title}</h2>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{rentals.map(rental => (
|
||||
<RentalCard key={rental.id} rental={rental} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
70
client/src/components/rentals/ReviewCard.tsx
Normal file
70
client/src/components/rentals/ReviewCard.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Star, MessageSquare } from 'lucide-react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import type { RentalReview } from '../../types/rental';
|
||||
import { formatDate } from '../../utils/format';
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: RentalReview;
|
||||
}
|
||||
|
||||
export function ReviewCard({ review }: ReviewCardProps) {
|
||||
const tenantName = review.tenant?.fullName ?? 'Anonymous';
|
||||
const tenantAvatar = review.tenant?.avatar;
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar
|
||||
src={tenantAvatar}
|
||||
name={tenantName}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-gray-900 truncate">
|
||||
{tenantName}
|
||||
</p>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0 ml-2">
|
||||
{formatDate(review.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stars */}
|
||||
<div className="flex items-center gap-0.5 mt-0.5">
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 ${
|
||||
i < review.rating
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
{review.comment && (
|
||||
<p className="mt-3 text-sm text-gray-600 leading-relaxed">
|
||||
{review.comment}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Landlord Response */}
|
||||
{review.landlordResponse && (
|
||||
<div className="mt-3 bg-gray-50 rounded-xl p-3 border border-gray-100">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-500" />
|
||||
<span className="text-xs font-semibold text-primary-600">Landlord Response</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 leading-relaxed">
|
||||
{review.landlordResponse}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
client/src/components/rentals/ReviewForm.tsx
Normal file
111
client/src/components/rentals/ReviewForm.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { GradientButton } from '../ui/GradientButton';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface ReviewFormProps {
|
||||
bookingId: string;
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
export function ReviewForm({ bookingId, onSubmit }: ReviewFormProps) {
|
||||
const [rating, setRating] = useState(0);
|
||||
const [hoverRating, setHoverRating] = useState(0);
|
||||
const [comment, setComment] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const displayRating = hoverRating || rating;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (rating === 0) {
|
||||
setError('Please select a rating');
|
||||
return;
|
||||
}
|
||||
if (!comment.trim()) {
|
||||
setError('Please write a comment');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.post(`/bookings/${bookingId}/reviews`, {
|
||||
rating,
|
||||
comment: comment.trim(),
|
||||
});
|
||||
onSubmit();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to submit review');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||||
<h3 className="text-lg font-bold text-gray-900 mb-4">Write a Review</h3>
|
||||
|
||||
{/* Star Rating */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Rating</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: 5 }, (_, i) => {
|
||||
const starValue = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={starValue}
|
||||
type="button"
|
||||
onClick={() => setRating(starValue)}
|
||||
onMouseEnter={() => setHoverRating(starValue)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-0.5 cursor-pointer transition-transform hover:scale-110"
|
||||
>
|
||||
<Star
|
||||
className={`w-7 h-7 transition-colors ${
|
||||
starValue <= displayRating
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-gray-200 hover:text-amber-200'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{rating > 0 && (
|
||||
<span className="ml-2 text-sm text-gray-500">
|
||||
{rating === 1 && 'Poor'}
|
||||
{rating === 2 && 'Fair'}
|
||||
{rating === 3 && 'Good'}
|
||||
{rating === 4 && 'Very Good'}
|
||||
{rating === 5 && 'Excellent'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comment */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Your Review</label>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Share your experience..."
|
||||
rows={4}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900
|
||||
placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none
|
||||
transition-all duration-200 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mb-3 text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
|
||||
<GradientButton type="submit" isLoading={submitting} disabled={submitting}>
|
||||
Submit Review
|
||||
</GradientButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
115
client/src/components/ui/DataTable.tsx
Normal file
115
client/src/components/ui/DataTable.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface Column<T> {
|
||||
key: string;
|
||||
header: string;
|
||||
render?: (item: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
columns: Column<T>[];
|
||||
data: T[];
|
||||
total?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
searchValue?: string;
|
||||
onSearch?: (value: string) => void;
|
||||
searchPlaceholder?: string;
|
||||
actions?: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<T extends { id?: string }>({
|
||||
columns,
|
||||
data,
|
||||
total = 0,
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
onPageChange,
|
||||
searchValue,
|
||||
onSearch,
|
||||
searchPlaceholder = 'Search...',
|
||||
actions,
|
||||
}: DataTableProps<T>) {
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{onSearch && (
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg border border-gray-200 text-sm focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<th key={col.key} className="text-left px-4 py-3 font-medium text-gray-500">
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
{actions && <th className="text-right px-4 py-3 font-medium text-gray-500">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (actions ? 1 : 0)} className="px-4 py-8 text-center text-gray-400">
|
||||
No data found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((item, i) => (
|
||||
<tr key={(item as any).id || i} className="border-b border-gray-50 hover:bg-gray-50">
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className="px-4 py-3">
|
||||
{col.render ? col.render(item) : (item as any)[col.key]}
|
||||
</td>
|
||||
))}
|
||||
{actions && <td className="px-4 py-3 text-right">{actions(item)}</td>}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && onPageChange && (
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-gray-100">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 text-sm font-medium">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30 cursor-pointer disabled:cursor-default"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
client/src/components/ui/StatCard.tsx
Normal file
32
client/src/components/ui/StatCard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function StatCard({ icon: Icon, label, value, trend, color = 'primary' }: StatCardProps) {
|
||||
const colorMap: Record<string, string> = {
|
||||
primary: 'bg-primary-50 text-primary-600',
|
||||
green: 'bg-green-50 text-green-600',
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
yellow: 'bg-yellow-50 text-yellow-600',
|
||||
pink: 'bg-pink-50 text-pink-600',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${colorMap[color] || colorMap.primary}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
{trend && <span className="text-xs font-medium text-green-600">{trend}</span>}
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,10 @@ interface AuthContextType {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isAdmin: boolean;
|
||||
isModerator: boolean;
|
||||
isSuperAdmin: boolean;
|
||||
isLandlord: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
signup: (data: { fullName: string; email: string; password: string }) => Promise<void>;
|
||||
logout: () => void;
|
||||
@@ -58,8 +62,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(prev => prev ? { ...prev, ...data } : null);
|
||||
}, []);
|
||||
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
const isModerator = user?.role === 'MODERATOR' || isAdmin;
|
||||
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
|
||||
const isLandlord = !!(user as any)?.isLandlord;
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, login, signup, logout, updateUser }}>
|
||||
<AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, isAdmin, isModerator, isSuperAdmin, isLandlord, login, signup, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
|
||||
323
client/src/pages/CreateRentalPage.tsx
Normal file
323
client/src/pages/CreateRentalPage.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Upload, X, ChevronRight, ChevronLeft, Check } from 'lucide-react';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { GradientButton } from '../components/ui/GradientButton';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { api } from '../api/client';
|
||||
import type { RentalCategory, CancellationPolicy } from '../types/rental';
|
||||
|
||||
const RENTAL_CATEGORIES: { value: RentalCategory; label: string; icon: string }[] = [
|
||||
{ value: 'APARTMENT', label: 'Apartment', icon: '\uD83C\uDFE2' },
|
||||
{ value: 'HOUSE', label: 'House', icon: '\uD83C\uDFE0' },
|
||||
{ value: 'CAR', label: 'Car', icon: '\uD83D\uDE97' },
|
||||
{ value: 'MOTORCYCLE', label: 'Motorcycle', icon: '\uD83C\uDFCD\uFE0F' },
|
||||
{ value: 'BICYCLE', label: 'Bicycle', icon: '\uD83D\uDEB2' },
|
||||
{ value: 'EBIKE', label: 'E-Bike', icon: '\u26A1' },
|
||||
];
|
||||
|
||||
const CANCELLATION_POLICIES: { value: CancellationPolicy; label: string; description: string }[] = [
|
||||
{ value: 'FLEXIBLE', label: 'Flexible', description: 'Free cancellation up to 24 hours before start' },
|
||||
{ value: 'MODERATE', label: 'Moderate', description: 'Free cancellation up to 5 days before start' },
|
||||
{ value: 'STRICT', label: 'Strict', description: 'No refund after booking confirmation' },
|
||||
];
|
||||
|
||||
const STEPS = ['Category', 'Details', 'Pricing', 'Photos'];
|
||||
|
||||
export function CreateRentalPage() {
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Step 1: Category
|
||||
const [category, setCategory] = useState<RentalCategory | ''>('');
|
||||
|
||||
// Step 2: Details
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [amenities, setAmenities] = useState('');
|
||||
const [rules, setRules] = useState('');
|
||||
|
||||
// Step 3: Pricing
|
||||
const [dailyPrice, setDailyPrice] = useState('');
|
||||
const [monthlyPrice, setMonthlyPrice] = useState('');
|
||||
const [depositAmount, setDepositAmount] = useState('');
|
||||
const [cancellationPolicy, setCancellationPolicy] = useState<CancellationPolicy>('FLEXIBLE');
|
||||
const [minDays, setMinDays] = useState('');
|
||||
const [maxDays, setMaxDays] = useState('');
|
||||
const [minMonths, setMinMonths] = useState('');
|
||||
const [maxMonths, setMaxMonths] = useState('');
|
||||
|
||||
// Step 4: Photos
|
||||
const [photos, setPhotos] = useState<File[]>([]);
|
||||
const [previews, setPreviews] = useState<string[]>([]);
|
||||
|
||||
const handleAddPhoto = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const remaining = 10 - photos.length;
|
||||
const newFiles = files.slice(0, remaining);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
setPhotos(prev => [...prev, ...newFiles]);
|
||||
setPreviews(prev => [...prev, ...newFiles.map(f => URL.createObjectURL(f))]);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRemovePhoto = (index: number) => {
|
||||
URL.revokeObjectURL(previews[index]);
|
||||
setPhotos(photos.filter((_, i) => i !== index));
|
||||
setPreviews(previews.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const canAdvance = () => {
|
||||
switch (step) {
|
||||
case 0: return !!category;
|
||||
case 1: return !!(title && description && location);
|
||||
case 2: return !!(dailyPrice || monthlyPrice);
|
||||
case 3: return true;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const amenitiesList = amenities.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const rulesList = rules.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
const rental = await api.post<{ id: string }>('/rentals', {
|
||||
category,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
amenities: amenitiesList,
|
||||
rules: rulesList,
|
||||
dailyPrice: dailyPrice ? parseFloat(dailyPrice) : undefined,
|
||||
monthlyPrice: monthlyPrice ? parseFloat(monthlyPrice) : undefined,
|
||||
depositAmount: depositAmount ? parseFloat(depositAmount) : undefined,
|
||||
cancellationPolicy,
|
||||
minDays: minDays ? parseInt(minDays) : undefined,
|
||||
maxDays: maxDays ? parseInt(maxDays) : undefined,
|
||||
minMonths: minMonths ? parseInt(minMonths) : undefined,
|
||||
maxMonths: maxMonths ? parseInt(maxMonths) : undefined,
|
||||
});
|
||||
|
||||
if (photos.length > 0) {
|
||||
const formData = new FormData();
|
||||
photos.forEach(file => formData.append('images', file));
|
||||
await api.upload(`/rentals/${rental.id}/images`, formData);
|
||||
}
|
||||
|
||||
navigate(`/rentals/${rental.id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create rental');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||
i < step ? 'bg-primary-600 text-white' : i === step ? 'bg-primary-100 text-primary-700 ring-2 ring-primary-400' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{i < step ? <Check className="w-4 h-4" /> : i + 1}
|
||||
</div>
|
||||
<span className={`hidden sm:block text-sm ${i === step ? 'font-medium text-gray-900' : 'text-gray-400'}`}>{label}</span>
|
||||
{i < STEPS.length - 1 && <div className="w-8 h-px bg-gray-200" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Category */}
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">What are you renting out?</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Select a category that best describes your rental.</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{RENTAL_CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.value)}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all cursor-pointer ${
|
||||
category === cat.value
|
||||
? 'border-primary-400 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl block mb-2">{cat.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Details */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Rental Details</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Provide information about your rental.</p>
|
||||
</div>
|
||||
<Input label="Title" placeholder="e.g. Cozy 2BR Apartment in Downtown" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
placeholder="Describe your rental in detail..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Input label="Location" placeholder="City, State or full address" value={location} onChange={(e) => setLocation(e.target.value)} required />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Amenities (one per line)</label>
|
||||
<textarea
|
||||
value={amenities}
|
||||
onChange={(e) => setAmenities(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={"WiFi\nParking\nAir Conditioning"}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Rules (one per line)</label>
|
||||
<textarea
|
||||
value={rules}
|
||||
onChange={(e) => setRules(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={"No smoking\nNo pets\nQuiet hours after 10pm"}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Pricing */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Pricing</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Set your rental prices. Fill in at least one pricing period.</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Daily Price ($)" type="number" placeholder="0.00" value={dailyPrice} onChange={(e) => setDailyPrice(e.target.value)} />
|
||||
<Input label="Monthly Price ($)" type="number" placeholder="0.00" value={monthlyPrice} onChange={(e) => setMonthlyPrice(e.target.value)} />
|
||||
</div>
|
||||
<Input label="Security Deposit ($)" type="number" placeholder="0.00" value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Cancellation Policy</label>
|
||||
<div className="space-y-2">
|
||||
{CANCELLATION_POLICIES.map(policy => (
|
||||
<button
|
||||
key={policy.value}
|
||||
type="button"
|
||||
onClick={() => setCancellationPolicy(policy.value)}
|
||||
className={`w-full text-left p-3 rounded-xl border-2 transition-all cursor-pointer ${
|
||||
cancellationPolicy === policy.value
|
||||
? 'border-primary-400 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900">{policy.label}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{policy.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Booking Duration Limits</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Input label="Min Days" type="number" placeholder="1" value={minDays} onChange={(e) => setMinDays(e.target.value)} />
|
||||
<Input label="Max Days" type="number" placeholder="30" value={maxDays} onChange={(e) => setMaxDays(e.target.value)} />
|
||||
<Input label="Min Months" type="number" placeholder="1" value={minMonths} onChange={(e) => setMinMonths(e.target.value)} />
|
||||
<Input label="Max Months" type="number" placeholder="12" value={maxMonths} onChange={(e) => setMaxMonths(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Photos */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Photos</h2>
|
||||
<p className="text-sm text-gray-500 mb-6">Add up to 10 photos. The first photo will be your cover image.</p>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} />
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3">
|
||||
{previews.map((src, i) => (
|
||||
<div key={i} className="relative aspect-square rounded-xl overflow-hidden">
|
||||
<img src={src} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemovePhoto(i)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{i === 0 && (
|
||||
<span className="absolute bottom-1 left-1 text-xs bg-black/60 text-white px-1.5 py-0.5 rounded">Cover</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{photos.length < 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhoto}
|
||||
className="aspect-square border-2 border-dashed border-gray-200 rounded-xl flex flex-col items-center justify-center gap-1 hover:border-primary-300 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Upload className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between mt-8">
|
||||
{step > 0 ? (
|
||||
<Button variant="secondary" onClick={() => setStep(step - 1)}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{step < STEPS.length - 1 ? (
|
||||
<GradientButton onClick={() => setStep(step + 1)} disabled={!canAdvance()}>
|
||||
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</GradientButton>
|
||||
) : (
|
||||
<GradientButton onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Creating...' : 'Create Rental'}
|
||||
</GradientButton>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
370
client/src/pages/EditRentalPage.tsx
Normal file
370
client/src/pages/EditRentalPage.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Upload, X, ChevronRight, ChevronLeft, Check } from 'lucide-react';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { GradientButton } from '../components/ui/GradientButton';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { api } from '../api/client';
|
||||
import type { RentalListing, RentalCategory, CancellationPolicy, RentalImage } from '../types/rental';
|
||||
|
||||
const RENTAL_CATEGORIES: { value: RentalCategory; label: string; icon: string }[] = [
|
||||
{ value: 'APARTMENT', label: 'Apartment', icon: '\uD83C\uDFE2' },
|
||||
{ value: 'HOUSE', label: 'House', icon: '\uD83C\uDFE0' },
|
||||
{ value: 'CAR', label: 'Car', icon: '\uD83D\uDE97' },
|
||||
{ value: 'MOTORCYCLE', label: 'Motorcycle', icon: '\uD83C\uDFCD\uFE0F' },
|
||||
{ value: 'BICYCLE', label: 'Bicycle', icon: '\uD83D\uDEB2' },
|
||||
{ value: 'EBIKE', label: 'E-Bike', icon: '\u26A1' },
|
||||
];
|
||||
|
||||
const CANCELLATION_POLICIES: { value: CancellationPolicy; label: string; description: string }[] = [
|
||||
{ value: 'FLEXIBLE', label: 'Flexible', description: 'Free cancellation up to 24 hours before start' },
|
||||
{ value: 'MODERATE', label: 'Moderate', description: 'Free cancellation up to 5 days before start' },
|
||||
{ value: 'STRICT', label: 'Strict', description: 'No refund after booking confirmation' },
|
||||
];
|
||||
|
||||
const STEPS = ['Category', 'Details', 'Pricing', 'Photos'];
|
||||
|
||||
export function EditRentalPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// Step 1: Category
|
||||
const [category, setCategory] = useState<RentalCategory | ''>('');
|
||||
|
||||
// Step 2: Details
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [amenities, setAmenities] = useState('');
|
||||
const [rules, setRules] = useState('');
|
||||
|
||||
// Step 3: Pricing
|
||||
const [dailyPrice, setDailyPrice] = useState('');
|
||||
const [monthlyPrice, setMonthlyPrice] = useState('');
|
||||
const [depositAmount, setDepositAmount] = useState('');
|
||||
const [cancellationPolicy, setCancellationPolicy] = useState<CancellationPolicy>('FLEXIBLE');
|
||||
const [minDays, setMinDays] = useState('');
|
||||
const [maxDays, setMaxDays] = useState('');
|
||||
const [minMonths, setMinMonths] = useState('');
|
||||
const [maxMonths, setMaxMonths] = useState('');
|
||||
|
||||
// Step 4: Photos
|
||||
const [existingImages, setExistingImages] = useState<RentalImage[]>([]);
|
||||
const [removedImageIds, setRemovedImageIds] = useState<string[]>([]);
|
||||
const [newPhotos, setNewPhotos] = useState<File[]>([]);
|
||||
const [newPreviews, setNewPreviews] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.get<RentalListing>(`/rentals/${id}`)
|
||||
.then(data => {
|
||||
setCategory(data.category);
|
||||
setTitle(data.title);
|
||||
setDescription(data.description);
|
||||
setLocation(data.location);
|
||||
setAmenities(data.amenities.join('\n'));
|
||||
setRules(data.rules.join('\n'));
|
||||
setDailyPrice(data.dailyPrice != null ? String(data.dailyPrice) : '');
|
||||
setMonthlyPrice(data.monthlyPrice != null ? String(data.monthlyPrice) : '');
|
||||
setDepositAmount(data.depositAmount != null ? String(data.depositAmount) : '');
|
||||
setCancellationPolicy(data.cancellationPolicy);
|
||||
setMinDays(data.minDays != null ? String(data.minDays) : '');
|
||||
setMaxDays(data.maxDays != null ? String(data.maxDays) : '');
|
||||
setMinMonths(data.minMonths != null ? String(data.minMonths) : '');
|
||||
setMaxMonths(data.maxMonths != null ? String(data.maxMonths) : '');
|
||||
setExistingImages(data.images);
|
||||
})
|
||||
.catch(() => setError('Failed to load rental'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
const handleAddPhoto = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const totalImages = existingImages.length - removedImageIds.length + newPhotos.length;
|
||||
const remaining = 10 - totalImages;
|
||||
const newFiles = files.slice(0, remaining);
|
||||
if (newFiles.length === 0) return;
|
||||
|
||||
setNewPhotos(prev => [...prev, ...newFiles]);
|
||||
setNewPreviews(prev => [...prev, ...newFiles.map(f => URL.createObjectURL(f))]);
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleRemoveExistingImage = (imageId: string) => {
|
||||
setRemovedImageIds(prev => [...prev, imageId]);
|
||||
};
|
||||
|
||||
const handleRemoveNewPhoto = (index: number) => {
|
||||
URL.revokeObjectURL(newPreviews[index]);
|
||||
setNewPhotos(newPhotos.filter((_, i) => i !== index));
|
||||
setNewPreviews(newPreviews.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const canAdvance = () => {
|
||||
switch (step) {
|
||||
case 0: return !!category;
|
||||
case 1: return !!(title && description && location);
|
||||
case 2: return !!(dailyPrice || monthlyPrice);
|
||||
case 3: return true;
|
||||
default: return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!id) return;
|
||||
setError('');
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const amenitiesList = amenities.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
const rulesList = rules.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
await api.put(`/rentals/${id}`, {
|
||||
category,
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
amenities: amenitiesList,
|
||||
rules: rulesList,
|
||||
dailyPrice: dailyPrice ? parseFloat(dailyPrice) : null,
|
||||
monthlyPrice: monthlyPrice ? parseFloat(monthlyPrice) : null,
|
||||
depositAmount: depositAmount ? parseFloat(depositAmount) : null,
|
||||
cancellationPolicy,
|
||||
minDays: minDays ? parseInt(minDays) : null,
|
||||
maxDays: maxDays ? parseInt(maxDays) : null,
|
||||
minMonths: minMonths ? parseInt(minMonths) : null,
|
||||
maxMonths: maxMonths ? parseInt(maxMonths) : null,
|
||||
removedImageIds,
|
||||
});
|
||||
|
||||
if (newPhotos.length > 0) {
|
||||
const formData = new FormData();
|
||||
newPhotos.forEach(file => formData.append('images', file));
|
||||
await api.upload(`/rentals/${id}/images`, formData);
|
||||
}
|
||||
|
||||
navigate(`/rentals/${id}`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update rental');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="max-w-3xl mx-auto px-4 py-12 text-center text-gray-500">Loading...</div>;
|
||||
|
||||
const keptImages = existingImages.filter(img => !removedImageIds.includes(img.id));
|
||||
const totalImageCount = keptImages.length + newPhotos.length;
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{STEPS.map((label, i) => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||
i < step ? 'bg-primary-600 text-white' : i === step ? 'bg-primary-100 text-primary-700 ring-2 ring-primary-400' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
{i < step ? <Check className="w-4 h-4" /> : i + 1}
|
||||
</div>
|
||||
<span className={`hidden sm:block text-sm ${i === step ? 'font-medium text-gray-900' : 'text-gray-400'}`}>{label}</span>
|
||||
{i < STEPS.length - 1 && <div className="w-8 h-px bg-gray-200" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card padding="lg">
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-6">Edit Rental</h1>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-xl text-sm text-red-600">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Category */}
|
||||
{step === 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-4">Category</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{RENTAL_CATEGORIES.map(cat => (
|
||||
<button
|
||||
key={cat.value}
|
||||
type="button"
|
||||
onClick={() => setCategory(cat.value)}
|
||||
className={`p-4 rounded-xl border-2 text-center transition-all cursor-pointer ${
|
||||
category === cat.value
|
||||
? 'border-primary-400 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-3xl block mb-2">{cat.icon}</span>
|
||||
<span className="text-sm font-medium text-gray-900">{cat.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Details */}
|
||||
{step === 1 && (
|
||||
<div className="space-y-5">
|
||||
<Input label="Title" placeholder="e.g. Cozy 2BR Apartment in Downtown" value={title} onChange={(e) => setTitle(e.target.value)} required />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={4}
|
||||
required
|
||||
placeholder="Describe your rental in detail..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<Input label="Location" placeholder="City, State or full address" value={location} onChange={(e) => setLocation(e.target.value)} required />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Amenities (one per line)</label>
|
||||
<textarea
|
||||
value={amenities}
|
||||
onChange={(e) => setAmenities(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={"WiFi\nParking\nAir Conditioning"}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Rules (one per line)</label>
|
||||
<textarea
|
||||
value={rules}
|
||||
onChange={(e) => setRules(e.target.value)}
|
||||
rows={3}
|
||||
placeholder={"No smoking\nNo pets\nQuiet hours after 10pm"}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-900 placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Pricing */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input label="Daily Price ($)" type="number" placeholder="0.00" value={dailyPrice} onChange={(e) => setDailyPrice(e.target.value)} />
|
||||
<Input label="Monthly Price ($)" type="number" placeholder="0.00" value={monthlyPrice} onChange={(e) => setMonthlyPrice(e.target.value)} />
|
||||
</div>
|
||||
<Input label="Security Deposit ($)" type="number" placeholder="0.00" value={depositAmount} onChange={(e) => setDepositAmount(e.target.value)} />
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Cancellation Policy</label>
|
||||
<div className="space-y-2">
|
||||
{CANCELLATION_POLICIES.map(policy => (
|
||||
<button
|
||||
key={policy.value}
|
||||
type="button"
|
||||
onClick={() => setCancellationPolicy(policy.value)}
|
||||
className={`w-full text-left p-3 rounded-xl border-2 transition-all cursor-pointer ${
|
||||
cancellationPolicy === policy.value
|
||||
? 'border-primary-400 bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900">{policy.label}</span>
|
||||
<p className="text-xs text-gray-500 mt-0.5">{policy.description}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Booking Duration Limits</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<Input label="Min Days" type="number" placeholder="1" value={minDays} onChange={(e) => setMinDays(e.target.value)} />
|
||||
<Input label="Max Days" type="number" placeholder="30" value={maxDays} onChange={(e) => setMaxDays(e.target.value)} />
|
||||
<Input label="Min Months" type="number" placeholder="1" value={minMonths} onChange={(e) => setMinMonths(e.target.value)} />
|
||||
<Input label="Max Months" type="number" placeholder="12" value={maxMonths} onChange={(e) => setMaxMonths(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Photos */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-900 mb-2">Photos</h2>
|
||||
<p className="text-xs text-gray-500 mb-4">Up to 10 photos. First photo is the cover image.</p>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" multiple className="hidden" onChange={handleFileChange} />
|
||||
<div className="grid grid-cols-3 sm:grid-cols-5 gap-3">
|
||||
{/* Existing images */}
|
||||
{keptImages.map((img, i) => (
|
||||
<div key={img.id} className="relative aspect-square rounded-xl overflow-hidden">
|
||||
<img src={img.url} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveExistingImage(img.id)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{i === 0 && newPhotos.length === 0 && (
|
||||
<span className="absolute bottom-1 left-1 text-xs bg-black/60 text-white px-1.5 py-0.5 rounded">Cover</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* New photos */}
|
||||
{newPreviews.map((src, i) => (
|
||||
<div key={`new-${i}`} className="relative aspect-square rounded-xl overflow-hidden">
|
||||
<img src={src} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveNewPhoto(i)}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white rounded-full flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{totalImageCount < 10 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddPhoto}
|
||||
className="aspect-square border-2 border-dashed border-gray-200 rounded-xl flex flex-col items-center justify-center gap-1 hover:border-primary-300 hover:bg-primary-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<Upload className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">Add</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between mt-8">
|
||||
{step > 0 ? (
|
||||
<Button variant="secondary" onClick={() => setStep(step - 1)}>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" /> Back
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{step < STEPS.length - 1 ? (
|
||||
<GradientButton onClick={() => setStep(step + 1)} disabled={!canAdvance()}>
|
||||
Next <ChevronRight className="w-4 h-4 ml-1" />
|
||||
</GradientButton>
|
||||
) : (
|
||||
<GradientButton onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? 'Saving...' : 'Save Changes'}
|
||||
</GradientButton>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
216
client/src/pages/MyBookingsPage.tsx
Normal file
216
client/src/pages/MyBookingsPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable } from '../components/ui/DataTable';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { GradientButton } from '../components/ui/GradientButton';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { BookingStatusBadge } from '../components/rentals/BookingStatusBadge';
|
||||
import { api } from '../api/client';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
import type { Booking, BookingStatus } from '../types/rental';
|
||||
|
||||
const STATUS_TABS: (BookingStatus | 'ALL')[] = ['ALL', 'PENDING', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED_BY_TENANT', 'CANCELLED_BY_LANDLORD', 'REJECTED'];
|
||||
|
||||
export function MyBookingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [tab, setTab] = useState<BookingStatus | 'ALL'>('ALL');
|
||||
const [, setLoading] = useState(true);
|
||||
|
||||
// Review modal
|
||||
const [showReview, setShowReview] = useState(false);
|
||||
const [reviewBookingId, setReviewBookingId] = useState('');
|
||||
const [reviewRating, setReviewRating] = useState(5);
|
||||
const [reviewComment, setReviewComment] = useState('');
|
||||
const [reviewSubmitting, setReviewSubmitting] = useState(false);
|
||||
|
||||
const fetchBookings = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20', role: 'tenant' });
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
|
||||
try {
|
||||
const res = await api.get<Booking[]>(`/bookings?${params}`);
|
||||
setBookings(res);
|
||||
setTotal(res.length);
|
||||
} catch {
|
||||
setBookings([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, [fetchBookings]);
|
||||
|
||||
const handleCancel = async (bookingId: string) => {
|
||||
const reason = window.prompt('Reason for cancellation:');
|
||||
if (!reason) return;
|
||||
try {
|
||||
await api.patch(`/bookings/${bookingId}/cancel`, { reason });
|
||||
fetchBookings();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handlePay = async (bookingId: string) => {
|
||||
try {
|
||||
await api.post<{ clientSecret: string }>('/rental-payments/create-intent', { bookingId });
|
||||
alert('Payment intent created. In production, Stripe checkout would open here.');
|
||||
fetchBookings();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const openReview = (bookingId: string) => {
|
||||
setReviewBookingId(bookingId);
|
||||
setReviewRating(5);
|
||||
setReviewComment('');
|
||||
setShowReview(true);
|
||||
};
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
setReviewSubmitting(true);
|
||||
try {
|
||||
await api.post('/rental-reviews', {
|
||||
bookingId: reviewBookingId,
|
||||
rating: reviewRating,
|
||||
comment: reviewComment || undefined,
|
||||
});
|
||||
setShowReview(false);
|
||||
fetchBookings();
|
||||
} catch {} finally {
|
||||
setReviewSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">My Bookings</h1>
|
||||
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'rental',
|
||||
header: 'Rental',
|
||||
render: (b: Booking) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{b.rentalListing.images[0] ? (
|
||||
<img src={b.rentalListing.images[0].url} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{b.rentalListing.title}</p>
|
||||
<p className="text-xs text-gray-400">{b.rentalListing.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Dates',
|
||||
render: (b: Booking) => (
|
||||
<div className="text-sm">
|
||||
<p>{new Date(b.startDate).toLocaleDateString()} - {new Date(b.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-gray-400">{b.totalPeriods} {b.periodType === 'DAILY' ? 'day(s)' : 'month(s)'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
header: 'Total',
|
||||
render: (b: Booking) => <span className="font-medium text-primary-600">{formatCurrency(b.totalAmount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (b: Booking) => <BookingStatusBadge status={b.status} />,
|
||||
},
|
||||
]}
|
||||
data={bookings}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
actions={(b: Booking) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/rentals/${b.rentalListingId}`)}>
|
||||
View
|
||||
</Button>
|
||||
{b.status === 'PENDING' && (
|
||||
<Button variant="secondary" size="sm" onClick={() => handleCancel(b.id)}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{b.status === 'CONFIRMED' && (
|
||||
<GradientButton size="sm" onClick={() => handlePay(b.id)}>
|
||||
Pay
|
||||
</GradientButton>
|
||||
)}
|
||||
{b.status === 'COMPLETED' && !b.review && (
|
||||
<Button variant="secondary" size="sm" onClick={() => openReview(b.id)}>
|
||||
Review
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Review Modal */}
|
||||
<Modal isOpen={showReview} onClose={() => setShowReview(false)} title="Leave a Review" size="sm">
|
||||
<div className="space-y-4 mb-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Rating</label>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setReviewRating(star)}
|
||||
className={`text-2xl cursor-pointer ${star <= reviewRating ? 'text-yellow-400' : 'text-gray-200'}`}
|
||||
>
|
||||
{'\u2605'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Comment (optional)</label>
|
||||
<textarea
|
||||
value={reviewComment}
|
||||
onChange={(e) => setReviewComment(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Share your experience..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setShowReview(false)}>Cancel</Button>
|
||||
<GradientButton className="flex-1" onClick={handleSubmitReview} disabled={reviewSubmitting}>
|
||||
{reviewSubmitting ? 'Submitting...' : 'Submit Review'}
|
||||
</GradientButton>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Bell, Check, Heart, Star, MessageSquare, Tag } from 'lucide-react';
|
||||
import { Bell, Check, Heart, Star, MessageSquare, Tag, Shield, AlertTriangle, Ban, UserCheck, FileCheck, CalendarCheck, CalendarX, DollarSign, Home } from 'lucide-react';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { api } from '../api/client';
|
||||
import { formatDate } from '../utils/format';
|
||||
@@ -13,6 +13,21 @@ const iconMap: Record<NotificationType, typeof Bell> = {
|
||||
ITEM_SOLD: Star,
|
||||
NEW_MESSAGE: MessageSquare,
|
||||
ITEM_FAVORITED: Heart,
|
||||
LISTING_APPROVED: FileCheck,
|
||||
LISTING_REJECTED: AlertTriangle,
|
||||
MODERATION_WARNING: Shield,
|
||||
ACCOUNT_BANNED: Ban,
|
||||
ACCOUNT_UNBANNED: UserCheck,
|
||||
REPORT_RESOLVED: Check,
|
||||
BOOKING_REQUEST: CalendarCheck,
|
||||
BOOKING_CONFIRMED: CalendarCheck,
|
||||
BOOKING_REJECTED: CalendarX,
|
||||
BOOKING_CANCELLED: CalendarX,
|
||||
BOOKING_STARTED: Home,
|
||||
BOOKING_COMPLETED: Check,
|
||||
RENTAL_REVIEW: Star,
|
||||
PAYOUT_SENT: DollarSign,
|
||||
PAYOUT_FAILED: AlertTriangle,
|
||||
};
|
||||
|
||||
const iconColorMap: Record<NotificationType, string> = {
|
||||
@@ -22,6 +37,21 @@ const iconColorMap: Record<NotificationType, string> = {
|
||||
ITEM_SOLD: 'text-yellow-500 bg-yellow-50',
|
||||
NEW_MESSAGE: 'text-blue-500 bg-blue-50',
|
||||
ITEM_FAVORITED: 'text-pink-500 bg-pink-50',
|
||||
LISTING_APPROVED: 'text-green-500 bg-green-50',
|
||||
LISTING_REJECTED: 'text-red-500 bg-red-50',
|
||||
MODERATION_WARNING: 'text-orange-500 bg-orange-50',
|
||||
ACCOUNT_BANNED: 'text-red-500 bg-red-50',
|
||||
ACCOUNT_UNBANNED: 'text-green-500 bg-green-50',
|
||||
REPORT_RESOLVED: 'text-blue-500 bg-blue-50',
|
||||
BOOKING_REQUEST: 'text-primary-500 bg-primary-50',
|
||||
BOOKING_CONFIRMED: 'text-green-500 bg-green-50',
|
||||
BOOKING_REJECTED: 'text-red-500 bg-red-50',
|
||||
BOOKING_CANCELLED: 'text-orange-500 bg-orange-50',
|
||||
BOOKING_STARTED: 'text-blue-500 bg-blue-50',
|
||||
BOOKING_COMPLETED: 'text-green-500 bg-green-50',
|
||||
RENTAL_REVIEW: 'text-yellow-500 bg-yellow-50',
|
||||
PAYOUT_SENT: 'text-green-500 bg-green-50',
|
||||
PAYOUT_FAILED: 'text-red-500 bg-red-50',
|
||||
};
|
||||
|
||||
export function NotificationsPage() {
|
||||
@@ -51,6 +81,14 @@ export function NotificationsPage() {
|
||||
} else if (notif.type === 'ITEM_FAVORITED' || notif.type === 'ITEM_SOLD') {
|
||||
const listingId = (notif.data as { listingId?: string })?.listingId;
|
||||
if (listingId) navigate(`/listings/${listingId}`);
|
||||
} else if (notif.type === 'BOOKING_REQUEST' || notif.type === 'BOOKING_CANCELLED') {
|
||||
navigate('/landlord/bookings');
|
||||
} else if (notif.type === 'BOOKING_CONFIRMED' || notif.type === 'BOOKING_REJECTED' || notif.type === 'BOOKING_COMPLETED') {
|
||||
navigate('/dashboard/bookings');
|
||||
} else if (notif.type === 'PAYOUT_SENT' || notif.type === 'PAYOUT_FAILED') {
|
||||
navigate('/landlord/payouts');
|
||||
} else if (notif.type === 'RENTAL_REVIEW') {
|
||||
navigate('/landlord/reviews');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Modal } from '../components/ui/Modal';
|
||||
import { Input } from '../components/ui/Input';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ReportModal } from '../components/ReportModal';
|
||||
import { formatCurrency, formatDate } from '../utils/format';
|
||||
import type { Listing } from '../types';
|
||||
|
||||
@@ -25,6 +26,7 @@ export function ProductDetailPage() {
|
||||
const [offerAmount, setOfferAmount] = useState('');
|
||||
const [offerMessage, setOfferMessage] = useState('');
|
||||
const [offerError, setOfferError] = useState('');
|
||||
const [showReport, setShowReport] = useState(false);
|
||||
|
||||
// Edit form state
|
||||
const [editTitle, setEditTitle] = useState('');
|
||||
@@ -227,12 +229,14 @@ export function ProductDetailPage() {
|
||||
<div className="flex gap-4 text-sm text-gray-400">
|
||||
<button onClick={() => { navigator.clipboard.writeText(window.location.href); alert('Link copied to clipboard!'); }}
|
||||
className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Share2 className="w-4 h-4" /> Share</button>
|
||||
<button onClick={() => alert('Thank you for your report. Our team will review this listing.')}
|
||||
<button onClick={() => setShowReport(true)}
|
||||
className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"><Flag className="w-4 h-4" /> Report</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{listing && <ReportModal isOpen={showReport} onClose={() => setShowReport(false)} targetType="LISTING" targetId={listing.id} />}
|
||||
|
||||
{/* Make Offer Modal */}
|
||||
<Modal isOpen={showOffer} onClose={() => setShowOffer(false)} title="Make Offer" size="sm">
|
||||
<p className="text-sm text-gray-500 mb-4">Enter your offer amount and message to the seller</p>
|
||||
|
||||
288
client/src/pages/RentalDetailPage.tsx
Normal file
288
client/src/pages/RentalDetailPage.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Badge } from '../components/ui/Badge';
|
||||
import { Avatar } from '../components/ui/Avatar';
|
||||
import { AvailabilityCalendar } from '../components/rentals/AvailabilityCalendar';
|
||||
import { BookingForm } from '../components/rentals/BookingForm';
|
||||
import { ReviewCard } from '../components/rentals/ReviewCard';
|
||||
import { PriceDisplay } from '../components/rentals/PriceDisplay';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { formatDate } from '../utils/format';
|
||||
import type { RentalListing, RentalReview } from '../types/rental';
|
||||
|
||||
export function RentalDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [rental, setRental] = useState<RentalListing | null>(null);
|
||||
const [reviews, setReviews] = useState<RentalReview[]>([]);
|
||||
const [blockedDates, setBlockedDates] = useState<Array<{ start: string; end: string }>>([]);
|
||||
const [bookedDates, setBookedDates] = useState<Array<{ start: string; end: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isFav, setIsFav] = useState(false);
|
||||
const [activeImage, setActiveImage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
api.get<RentalListing>(`/rentals/${id}`)
|
||||
.then(data => {
|
||||
setRental(data);
|
||||
setIsFav(data.isFavorited ?? false);
|
||||
setReviews(data.reviews ?? []);
|
||||
})
|
||||
.catch(() => setRental(null))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
api.get<{ blocks: Array<{ startDate: string; endDate: string; isBlocked: boolean }>; bookings: Array<{ startDate: string; endDate: string }> }>(`/rentals/${id}/availability`)
|
||||
.then(data => {
|
||||
setBlockedDates(data.blocks.filter(b => b.isBlocked).map(b => ({ start: b.startDate, end: b.endDate })));
|
||||
setBookedDates(data.bookings.map(b => ({ start: b.startDate, end: b.endDate })));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [id]);
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (!rental || !isAuthenticated) return;
|
||||
try {
|
||||
const res = await api.post<{ isFavorited: boolean }>(`/rentals/${rental.id}/favorite`);
|
||||
setIsFav(res.isFavorited);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleMessage = async () => {
|
||||
if (!rental || !isAuthenticated) return;
|
||||
try {
|
||||
const conversation = await api.post<{ id: string }>('/chat/conversations', {
|
||||
recipientId: rental.landlord.id,
|
||||
rentalListingId: rental.id,
|
||||
});
|
||||
navigate('/dashboard/messages', { state: { conversationId: conversation.id } });
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (loading) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Loading...</div>;
|
||||
if (!rental) return <div className="max-w-7xl mx-auto px-4 py-12 text-center text-gray-500">Rental not found</div>;
|
||||
|
||||
const isOwner = user?.id === rental.landlordId;
|
||||
const hasImages = rental.images && rental.images.length > 0;
|
||||
const cancellationLabel = rental.cancellationPolicy === 'FLEXIBLE' ? 'Free cancellation up to 24h before'
|
||||
: rental.cancellationPolicy === 'MODERATE' ? 'Free cancellation up to 5 days before'
|
||||
: 'No refund after booking confirmation';
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Image Gallery */}
|
||||
<div className="mb-8">
|
||||
<div className="relative aspect-[16/9] md:aspect-[2/1] bg-gradient-to-br from-primary-50 to-pink-50 rounded-2xl overflow-hidden mb-3">
|
||||
{hasImages ? (
|
||||
<img src={rental.images[activeImage]?.url} alt={rental.title} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<span className="text-8xl">
|
||||
{rental.category === 'APARTMENT' ? '\uD83C\uDFE2' : rental.category === 'HOUSE' ? '\uD83C\uDFE0' : rental.category === 'CAR' ? '\uD83D\uDE97' : '\uD83D\uDEB2'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasImages && rental.images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setActiveImage(prev => prev > 0 ? prev - 1 : rental.images.length - 1)}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-9 h-9 bg-white/80 backdrop-blur rounded-full flex items-center justify-center hover:bg-white transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveImage(prev => prev < rental.images.length - 1 ? prev + 1 : 0)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 w-9 h-9 bg-white/80 backdrop-blur rounded-full flex items-center justify-center hover:bg-white transition-colors cursor-pointer"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5 text-gray-700" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{hasImages && rental.images.length > 1 && (
|
||||
<div className="grid grid-cols-5 sm:grid-cols-6 gap-2">
|
||||
{rental.images.slice(0, 6).map((img, i) => (
|
||||
<button
|
||||
key={img.id}
|
||||
onClick={() => setActiveImage(i)}
|
||||
className={`aspect-square rounded-xl overflow-hidden cursor-pointer transition-all ${
|
||||
i === activeImage ? 'ring-2 ring-primary-400' : 'hover:ring-2 hover:ring-gray-300'
|
||||
}`}
|
||||
>
|
||||
<img src={img.url} alt="" className="w-full h-full object-cover" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Left column - Details */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Title and meta */}
|
||||
<div>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{rental.title}</h1>
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<Badge variant="default" size="md">{rental.category.replace('_', ' ')}</Badge>
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<MapPin className="w-4 h-4" /> {rental.location}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-sm text-gray-400">
|
||||
<Eye className="w-4 h-4" /> {rental.viewCount} views
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={handleFavorite} className="p-2 rounded-xl hover:bg-gray-100 transition-colors cursor-pointer">
|
||||
<Heart className={`w-6 h-6 ${isFav ? 'fill-pink-500 text-pink-500' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PriceDisplay
|
||||
dailyPrice={rental.dailyPrice ?? undefined}
|
||||
monthlyPrice={rental.monthlyPrice ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Description</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{rental.description}</p>
|
||||
</Card>
|
||||
|
||||
{/* Amenities */}
|
||||
{rental.amenities.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rental.amenities.map((amenity, i) => (
|
||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-primary-50 text-primary-700 text-sm font-medium">
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rules */}
|
||||
{rental.rules.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">House Rules</h3>
|
||||
<ul className="space-y-2">
|
||||
{rental.rules.map((rule, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-gray-400 mt-0.5">--</span>
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Cancellation Policy */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Cancellation Policy</h3>
|
||||
<Badge variant={rental.cancellationPolicy === 'FLEXIBLE' ? 'success' : rental.cancellationPolicy === 'MODERATE' ? 'warning' : 'error'} size="md">
|
||||
{rental.cancellationPolicy}
|
||||
</Badge>
|
||||
<p className="text-sm text-gray-500 mt-2">{cancellationLabel}</p>
|
||||
</Card>
|
||||
|
||||
{/* Availability Calendar */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Availability</h3>
|
||||
<AvailabilityCalendar blockedDates={blockedDates} bookedDates={bookedDates} />
|
||||
</Card>
|
||||
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Reviews</h3>
|
||||
{rental.avgRating !== undefined && (
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
{rental.avgRating.toFixed(1)}
|
||||
{rental._count?.reviews !== undefined && (
|
||||
<span className="text-gray-400">({rental._count.reviews})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No reviews yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map(review => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => { navigator.clipboard.writeText(window.location.href); alert('Link copied to clipboard!'); }}
|
||||
className="flex items-center gap-1 hover:text-gray-600 cursor-pointer"
|
||||
>
|
||||
<Share2 className="w-4 h-4" /> Share
|
||||
</button>
|
||||
<button className="flex items-center gap-1 hover:text-gray-600 cursor-pointer">
|
||||
<Flag className="w-4 h-4" /> Report
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right column - Booking and Landlord */}
|
||||
<div className="space-y-6">
|
||||
{/* Booking form */}
|
||||
{!isOwner && (
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<Card padding="lg">
|
||||
<BookingForm rental={rental} />
|
||||
</Card>
|
||||
|
||||
<Button variant="outline" className="w-full" onClick={handleMessage}>
|
||||
<MessageSquare className="w-4 h-4 mr-2" /> Message Landlord
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Landlord card */}
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-3">Hosted by</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={rental.landlord.fullName} src={rental.landlord.avatar} size="lg" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{rental.landlord.fullName}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{rental.landlord.rating !== undefined && (
|
||||
<>
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium">{rental.landlord.rating}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">Joined {formatDate(rental.landlord.createdAt)}</span>
|
||||
</div>
|
||||
{rental.landlord.landlordVerified && (
|
||||
<span className="mt-1"><Badge variant="success" size="sm">Verified</Badge></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
client/src/pages/RentalsPage.tsx
Normal file
143
client/src/pages/RentalsPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { RentalGrid } from '../components/rentals/RentalGrid';
|
||||
import { RentalCategorySidebar } from '../components/rentals/RentalCategorySidebar';
|
||||
import { api } from '../api/client';
|
||||
import type { RentalListing } from '../types/rental';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'popular';
|
||||
|
||||
export function RentalsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortOption>('newest');
|
||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const pageSize = 12;
|
||||
|
||||
const fetchRentals = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
pageSize: String(pageSize),
|
||||
sort,
|
||||
});
|
||||
if (selectedCategory) params.set('category', selectedCategory as string);
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<RentalListing>>(`/rentals?${params}`);
|
||||
setRentals(res.data);
|
||||
setTotal(res.total);
|
||||
setTotalPages(res.totalPages);
|
||||
} catch {
|
||||
setRentals([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sort, selectedCategory, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
}, [fetchRentals]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string | null) => {
|
||||
setSelectedCategory(category);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSortChange = (value: string) => {
|
||||
setSort(value as SortOption);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Browse Rentals</h1>
|
||||
|
||||
<div className="flex gap-8">
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden lg:block w-56 flex-shrink-0">
|
||||
<div className="sticky top-24">
|
||||
<RentalCategorySidebar selected={selectedCategory} onSelect={handleCategoryChange} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1">
|
||||
{/* Search and sort bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 mb-6">
|
||||
<form onSubmit={handleSearch} className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search rentals..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-xl border border-gray-200 bg-white text-sm
|
||||
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => handleSortChange(e.target.value)}
|
||||
className="rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm text-gray-700
|
||||
focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none"
|
||||
>
|
||||
<option value="newest">Newest First</option>
|
||||
<option value="price_asc">Price: Low to High</option>
|
||||
<option value="price_desc">Price: High to Low</option>
|
||||
<option value="popular">Most Popular</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{loading ? (
|
||||
<p className="text-gray-500 text-center py-12">Loading rentals...</p>
|
||||
) : rentals.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-12">No rentals found.</p>
|
||||
) : (
|
||||
<RentalGrid rentals={rentals} />
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 cursor-pointer disabled:cursor-default transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="px-3 text-sm font-medium">{page} / {totalPages}</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 disabled:opacity-30 cursor-pointer disabled:cursor-default transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
client/src/pages/admin/AdminBookingsPage.tsx
Normal file
130
client/src/pages/admin/AdminBookingsPage.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { BookingStatusBadge } from '../../components/rentals/BookingStatusBadge';
|
||||
import { api } from '../../api/client';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
import type { Booking, BookingStatus } from '../../types/rental';
|
||||
|
||||
const STATUS_TABS: (BookingStatus | 'ALL')[] = ['ALL', 'PENDING', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED_BY_TENANT', 'CANCELLED_BY_LANDLORD', 'REJECTED', 'EXPIRED'];
|
||||
|
||||
export function AdminBookingsPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState<BookingStatus | 'ALL'>('ALL');
|
||||
|
||||
const fetchBookings = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
|
||||
try {
|
||||
const res = await api.get<{ data: Booking[]; total: number }>(`/admin/bookings?${params}`);
|
||||
setBookings(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setBookings([]);
|
||||
}
|
||||
}, [page, search, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, [fetchBookings]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">All Bookings</h1>
|
||||
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'rental',
|
||||
header: 'Rental',
|
||||
render: (b: Booking) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{b.rentalListing.images[0] ? (
|
||||
<img src={b.rentalListing.images[0].url} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{b.rentalListing.title}</p>
|
||||
<p className="text-xs text-gray-400">{b.rentalListing.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
header: 'Tenant',
|
||||
render: (b: Booking) => (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{b.tenant.fullName}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'landlord',
|
||||
header: 'Landlord',
|
||||
render: (b: Booking) => (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{b.landlord.fullName}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Dates',
|
||||
render: (b: Booking) => (
|
||||
<div className="text-sm">
|
||||
<p>{new Date(b.startDate).toLocaleDateString()} - {new Date(b.endDate).toLocaleDateString()}</p>
|
||||
<p className="text-xs text-gray-400">{b.totalPeriods} {b.periodType === 'DAILY' ? 'day(s)' : 'month(s)'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
header: 'Total',
|
||||
render: (b: Booking) => <span className="font-medium">{formatCurrency(b.totalAmount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (b: Booking) => <BookingStatusBadge status={b.status} />,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (b: Booking) => new Date(b.createdAt).toLocaleDateString(),
|
||||
},
|
||||
]}
|
||||
data={bookings}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search bookings..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
client/src/pages/admin/AdminDashboardPage.tsx
Normal file
72
client/src/pages/admin/AdminDashboardPage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState as useAdminState } from 'react';
|
||||
import { Users, ShoppingBag, Tag, DollarSign, Activity, Home, CalendarCheck } from 'lucide-react';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminStats } from '../../types';
|
||||
|
||||
export function AdminDashboardPage() {
|
||||
const [stats, setStats] = useState<AdminStats | null>(null);
|
||||
const [rentalStats, setRentalStats] = useAdminState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<AdminStats>('/admin/stats').then(setStats).catch(() => {});
|
||||
api.get('/admin/rentals/stats').then(setRentalStats).catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!stats) return <div className="text-center text-gray-400 py-12">Loading dashboard...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 mb-8">
|
||||
<StatCard icon={Users} label="Total Users" value={stats.totalUsers} color="blue" />
|
||||
<StatCard icon={ShoppingBag} label="Active Listings" value={stats.activeListings} color="green" />
|
||||
<StatCard icon={Tag} label="Total Offers" value={stats.totalOffers} color="yellow" />
|
||||
<StatCard icon={DollarSign} label="Total Revenue" value={`$${stats.totalRevenue.toFixed(2)}`} color="pink" />
|
||||
<StatCard icon={Activity} label="Active Today" value={stats.activeToday} color="primary" />
|
||||
</div>
|
||||
|
||||
{rentalStats && (
|
||||
<>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4 mt-6">Rental Stats</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard icon={Home} label="Active Rentals" value={rentalStats.activeRentals} color="blue" />
|
||||
<StatCard icon={CalendarCheck} label="Active Bookings" value={rentalStats.activeBookings} color="green" />
|
||||
<StatCard icon={DollarSign} label="Rental Revenue" value={`$${(rentalStats.totalRentalRevenue || 0).toFixed(2)}`} color="pink" />
|
||||
<StatCard icon={Tag} label="Pending Rentals" value={rentalStats.pendingRentals} color="yellow" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Quick Stats</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Pending Reviews</span>
|
||||
<span className="font-medium text-yellow-600">{stats.pendingListings}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Total Listings</span>
|
||||
<span className="font-medium">{stats.totalListings}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Platform Health</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Active Listing Rate</span>
|
||||
<span className="font-medium">{stats.totalListings > 0 ? ((stats.activeListings / stats.totalListings) * 100).toFixed(0) : 0}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Avg Revenue/User</span>
|
||||
<span className="font-medium">${stats.totalUsers > 0 ? (stats.totalRevenue / stats.totalUsers).toFixed(2) : '0.00'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
client/src/pages/admin/AdminListingsPage.tsx
Normal file
95
client/src/pages/admin/AdminListingsPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface AdminListing {
|
||||
id: string;
|
||||
title: string;
|
||||
price: number;
|
||||
category: string;
|
||||
status: string;
|
||||
isFeatured: boolean;
|
||||
createdAt: string;
|
||||
viewCount: number;
|
||||
seller: { id: string; fullName: string; avatar?: string };
|
||||
images: { url: string }[];
|
||||
_count: { offers: number; favorites: number };
|
||||
}
|
||||
|
||||
const TABS = ['ALL', 'PENDING_REVIEW', 'ACTIVE', 'SOLD', 'DELETED'];
|
||||
|
||||
export function AdminListingsPage() {
|
||||
const [listings, setListings] = useState<AdminListing[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState('ALL');
|
||||
|
||||
const fetchListings = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
const res = await api.get<{ data: AdminListing[]; total: number }>(`/admin/listings?${params}`);
|
||||
setListings(res.data);
|
||||
setTotal(res.total);
|
||||
}, [page, search, tab]);
|
||||
|
||||
useEffect(() => { fetchListings(); }, [fetchListings]);
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const v = status === 'ACTIVE' ? 'success' : status === 'PENDING_REVIEW' ? 'warning' : status === 'SOLD' ? 'info' : 'error';
|
||||
return <Badge variant={v} size="sm">{status.replace('_', ' ')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Listings</h1>
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t.replace('_', ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'title',
|
||||
header: 'Listing',
|
||||
render: (l: AdminListing) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{l.images[0] ? <img src={l.images[0].url} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-gray-200" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{l.title}</p>
|
||||
<p className="text-xs text-gray-400">{l.seller.fullName}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'price', header: 'Price', render: (l: AdminListing) => `$${l.price.toFixed(2)}` },
|
||||
{ key: 'status', header: 'Status', render: (l: AdminListing) => statusBadge(l.status) },
|
||||
{ key: 'category', header: 'Category', render: (l: AdminListing) => l.category.replace('_', ' ') },
|
||||
{ key: 'views', header: 'Views', render: (l: AdminListing) => l.viewCount },
|
||||
{ key: 'createdAt', header: 'Created', render: (l: AdminListing) => new Date(l.createdAt).toLocaleDateString() },
|
||||
]}
|
||||
data={listings}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search listings..."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
client/src/pages/admin/AdminModerationPage.tsx
Normal file
111
client/src/pages/admin/AdminModerationPage.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface QueueItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
price: number;
|
||||
category: string;
|
||||
condition: string;
|
||||
location: string;
|
||||
createdAt: string;
|
||||
seller: { id: string; fullName: string; avatar?: string; email: string };
|
||||
images: { url: string; order: number }[];
|
||||
}
|
||||
|
||||
export function AdminModerationPage() {
|
||||
const [queue, setQueue] = useState<QueueItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [rejectId, setRejectId] = useState<string | null>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const fetchQueue = () => {
|
||||
api.get<{ data: QueueItem[]; total: number }>('/admin/moderation/queue')
|
||||
.then((res) => { setQueue(res.data); setTotal(res.total); })
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
useEffect(() => { fetchQueue(); }, []);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
await api.post(`/admin/listings/${id}/approve`);
|
||||
fetchQueue();
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
if (!rejectId || !rejectReason) return;
|
||||
await api.post(`/admin/listings/${rejectId}/reject`, { reason: rejectReason });
|
||||
setRejectId(null);
|
||||
setRejectReason('');
|
||||
fetchQueue();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Moderation Queue</h1>
|
||||
<Badge variant="warning" size="md">{total} pending</Badge>
|
||||
</div>
|
||||
|
||||
{queue.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-12 text-center">
|
||||
<p className="text-gray-400">No listings pending review</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{queue.map((item) => (
|
||||
<div key={item.id} className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<div className="flex gap-5">
|
||||
<div className="w-32 h-32 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{item.images[0] ? (
|
||||
<img src={item.images[0].url} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-4xl bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 text-lg">{item.title}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
${item.price.toFixed(2)} · {item.category} · {item.condition.replace('_', ' ')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{item.description}</p>
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
By {item.seller.fullName} ({item.seller.email}) · {new Date(item.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
||||
<Button variant="outline" size="sm" onClick={() => handleApprove(item.id)}>
|
||||
<CheckCircle className="w-4 h-4 mr-1 text-green-500" /> Approve
|
||||
</Button>
|
||||
<Button variant="danger" size="sm" onClick={() => setRejectId(item.id)}>
|
||||
<XCircle className="w-4 h-4 mr-1" /> Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal isOpen={!!rejectId} onClose={() => setRejectId(null)} title="Reject Listing" size="sm">
|
||||
<p className="text-sm text-gray-500 mb-4">Provide a reason for rejection. The seller will be notified.</p>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Reason for rejection..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setRejectId(null)}>Cancel</Button>
|
||||
<Button variant="danger" className="flex-1" onClick={handleReject} disabled={!rejectReason}>Reject</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
client/src/pages/admin/AdminPaymentsPage.tsx
Normal file
82
client/src/pages/admin/AdminPaymentsPage.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DollarSign, Tag, Megaphone, CreditCard } from 'lucide-react';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
amount: number;
|
||||
status: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
createdAt: string;
|
||||
user: { id: string; fullName: string };
|
||||
listing: { id: string; title: string };
|
||||
}
|
||||
|
||||
interface RevenueBreakdown {
|
||||
listingFees: { total: number; count: number };
|
||||
commissions: { total: number; count: number };
|
||||
promotions: { total: number; count: number };
|
||||
subscriptions: { total: number; count: number };
|
||||
}
|
||||
|
||||
export function AdminPaymentsPage() {
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [revenue, setRevenue] = useState<RevenueBreakdown | null>(null);
|
||||
|
||||
const fetchPayments = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
const res = await api.get<{ data: Payment[]; total: number }>(`/admin/payments?${params}`);
|
||||
setPayments(res.data);
|
||||
setTotal(res.total);
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayments();
|
||||
api.get<RevenueBreakdown>('/admin/payments/revenue').then(setRevenue).catch(() => {});
|
||||
}, [fetchPayments]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Payments</h1>
|
||||
|
||||
{revenue && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard icon={DollarSign} label="Listing Fees" value={`$${revenue.listingFees.total.toFixed(2)}`} color="green" />
|
||||
<StatCard icon={Tag} label="Commissions" value={`$${revenue.commissions.total.toFixed(2)}`} color="blue" />
|
||||
<StatCard icon={Megaphone} label="Promotions" value={`$${revenue.promotions.total.toFixed(2)}`} color="yellow" />
|
||||
<StatCard icon={CreditCard} label="Subscriptions" value={`$${revenue.subscriptions.total.toFixed(2)}`} color="pink" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'user', header: 'User', render: (p: Payment) => p.user.fullName },
|
||||
{ key: 'listing', header: 'Listing', render: (p: Payment) => <span className="truncate max-w-xs block">{p.listing.title}</span> },
|
||||
{ key: 'amount', header: 'Amount', render: (p: Payment) => `$${p.amount.toFixed(2)}` },
|
||||
{ key: 'type', header: 'Type', render: (p: Payment) => <Badge variant="default" size="sm">{p.type.replace('_', ' ')}</Badge> },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (p: Payment) => (
|
||||
<Badge variant={p.status === 'COMPLETED' ? 'success' : p.status === 'FAILED' ? 'error' : 'warning'} size="sm">
|
||||
{p.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{ key: 'createdAt', header: 'Date', render: (p: Payment) => new Date(p.createdAt).toLocaleDateString() },
|
||||
]}
|
||||
data={payments}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
154
client/src/pages/admin/AdminRentalPayoutsPage.tsx
Normal file
154
client/src/pages/admin/AdminRentalPayoutsPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DollarSign, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { api } from '../../api/client';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
import type { Payout, PayoutStatus } from '../../types/rental';
|
||||
|
||||
const STATUS_TABS: (PayoutStatus | 'ALL')[] = ['ALL', 'PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'];
|
||||
|
||||
interface PayoutStats {
|
||||
totalPending: number;
|
||||
totalProcessing: number;
|
||||
totalCompleted: number;
|
||||
totalFailed: number;
|
||||
pendingAmount: number;
|
||||
completedAmount: number;
|
||||
}
|
||||
|
||||
export function AdminRentalPayoutsPage() {
|
||||
const [payouts, setPayouts] = useState<Payout[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [tab, setTab] = useState<PayoutStatus | 'ALL'>('ALL');
|
||||
const [stats] = useState<PayoutStats | null>(null);
|
||||
|
||||
const fetchPayouts = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
|
||||
try {
|
||||
const res = await api.get<{ data: Payout[]; total: number }>(`/admin/rental-payouts?${params}`);
|
||||
setPayouts(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setPayouts([]);
|
||||
}
|
||||
}, [page, search, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayouts();
|
||||
}, [fetchPayouts]);
|
||||
|
||||
const handleRetry = async (payoutId: string) => {
|
||||
try {
|
||||
await api.patch(`/admin/rental-payouts/${payoutId}/retry`);
|
||||
fetchPayouts();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const statusBadge = (status: PayoutStatus) => {
|
||||
const v = status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'error' : status === 'PROCESSING' ? 'info' : 'warning';
|
||||
return <Badge variant={v} size="sm">{status}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Rental Payouts</h1>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<StatCard icon={Clock} label="Pending Payouts" value={stats.totalPending} color="yellow" />
|
||||
<StatCard icon={DollarSign} label="Pending Amount" value={formatCurrency(stats.pendingAmount)} color="blue" />
|
||||
<StatCard icon={CheckCircle} label="Completed" value={formatCurrency(stats.completedAmount)} color="green" />
|
||||
<StatCard icon={AlertCircle} label="Failed" value={stats.totalFailed} color="pink" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status filter tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'booking',
|
||||
header: 'Booking',
|
||||
render: (p: Payout) => (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{p.booking?.rentalListing?.title ?? 'N/A'}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{p.booking ? `${new Date(p.booking.startDate).toLocaleDateString()} - ${new Date(p.booking.endDate).toLocaleDateString()}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
header: 'Tenant',
|
||||
render: (p: Payout) => p.booking?.tenant?.fullName ?? 'N/A',
|
||||
},
|
||||
{
|
||||
key: 'gross',
|
||||
header: 'Gross',
|
||||
render: (p: Payout) => formatCurrency(p.grossAmount),
|
||||
},
|
||||
{
|
||||
key: 'commission',
|
||||
header: 'Commission',
|
||||
render: (p: Payout) => formatCurrency(p.commissionAmount),
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'Net Payout',
|
||||
render: (p: Payout) => <span className="font-medium text-primary-600">{formatCurrency(p.netAmount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (p: Payout) => statusBadge(p.status),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Date',
|
||||
render: (p: Payout) => new Date(p.createdAt).toLocaleDateString(),
|
||||
},
|
||||
]}
|
||||
data={payouts}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search payouts..."
|
||||
actions={(p: Payout) => (
|
||||
<div>
|
||||
{p.status === 'FAILED' && (
|
||||
<Button variant="secondary" size="sm" onClick={() => handleRetry(p.id)}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
client/src/pages/admin/AdminRentalsPage.tsx
Normal file
184
client/src/pages/admin/AdminRentalsPage.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { api } from '../../api/client';
|
||||
import { formatCurrency } from '../../utils/format';
|
||||
import type { RentalListing, RentalListingStatus, RentalCategory } from '../../types/rental';
|
||||
|
||||
const STATUS_TABS: (RentalListingStatus | 'ALL')[] = ['ALL', 'PENDING_REVIEW', 'ACTIVE', 'PAUSED', 'DELETED'];
|
||||
const CATEGORY_OPTIONS: (RentalCategory | 'ALL')[] = ['ALL', 'APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE'];
|
||||
|
||||
export function AdminRentalsPage() {
|
||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusTab, setStatusTab] = useState<RentalListingStatus | 'ALL'>('ALL');
|
||||
const [categoryFilter, setCategoryFilter] = useState<RentalCategory | 'ALL'>('ALL');
|
||||
|
||||
// Rejection modal
|
||||
const [showReject, setShowReject] = useState(false);
|
||||
const [rejectId, setRejectId] = useState('');
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
|
||||
const fetchRentals = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
if (statusTab !== 'ALL') params.set('status', statusTab);
|
||||
if (categoryFilter !== 'ALL') params.set('category', categoryFilter);
|
||||
|
||||
try {
|
||||
const res = await api.get<{ data: RentalListing[]; total: number }>(`/admin/rentals?${params}`);
|
||||
setRentals(res.data);
|
||||
setTotal(res.total);
|
||||
} catch {
|
||||
setRentals([]);
|
||||
}
|
||||
}, [page, search, statusTab, categoryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
}, [fetchRentals]);
|
||||
|
||||
const handleApprove = async (id: string) => {
|
||||
try {
|
||||
await api.patch(`/admin/rentals/${id}/approve`);
|
||||
fetchRentals();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const openReject = (id: string) => {
|
||||
setRejectId(id);
|
||||
setRejectReason('');
|
||||
setShowReject(true);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
try {
|
||||
await api.patch(`/admin/rentals/${rejectId}/reject`, { reason: rejectReason });
|
||||
setShowReject(false);
|
||||
fetchRentals();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this rental?')) return;
|
||||
try {
|
||||
await api.delete(`/admin/rentals/${id}`);
|
||||
fetchRentals();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const v = status === 'ACTIVE' ? 'success' : status === 'PENDING_REVIEW' ? 'warning' : status === 'PAUSED' ? 'info' : 'error';
|
||||
return <Badge variant={v} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Rental Listings</h1>
|
||||
|
||||
{/* Status tabs */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setStatusTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
statusTab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-sm text-gray-500">Category:</span>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value as RentalCategory | 'ALL'); setPage(1); }}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none"
|
||||
>
|
||||
{CATEGORY_OPTIONS.map(c => (
|
||||
<option key={c} value={c}>{c.replace(/_/g, ' ')}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'title',
|
||||
header: 'Rental',
|
||||
render: (r: RentalListing) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{r.images[0] ? <img src={r.images[0].url} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-gray-200" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{r.title}</p>
|
||||
<p className="text-xs text-gray-400">{r.landlord.fullName}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'category', header: 'Category', render: (r: RentalListing) => r.category.replace(/_/g, ' ') },
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Price',
|
||||
render: (r: RentalListing) => (
|
||||
<div className="text-sm">
|
||||
{r.dailyPrice != null && <p>{formatCurrency(r.dailyPrice)}/day</p>}
|
||||
{r.monthlyPrice != null && <p>{formatCurrency(r.monthlyPrice)}/mo</p>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'status', header: 'Status', render: (r: RentalListing) => statusBadge(r.status) },
|
||||
{ key: 'views', header: 'Views', render: (r: RentalListing) => r.viewCount },
|
||||
{ key: 'createdAt', header: 'Created', render: (r: RentalListing) => new Date(r.createdAt).toLocaleDateString() },
|
||||
]}
|
||||
data={rentals}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search rentals..."
|
||||
actions={(r: RentalListing) => (
|
||||
<div className="flex items-center gap-1">
|
||||
{r.status === 'PENDING_REVIEW' && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => handleApprove(r.id)}>Approve</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => openReject(r.id)}>Reject</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" onClick={() => handleDelete(r.id)}>Delete</Button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rejection reason modal */}
|
||||
<Modal isOpen={showReject} onClose={() => setShowReject(false)} title="Reject Rental" size="sm">
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Reason for rejection</label>
|
||||
<textarea
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Provide a reason..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setShowReject(false)}>Cancel</Button>
|
||||
<Button variant="danger" className="flex-1" onClick={handleReject}>Reject</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
client/src/pages/admin/AdminReportsPage.tsx
Normal file
105
client/src/pages/admin/AdminReportsPage.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { GradientButton } from '../../components/ui/GradientButton';
|
||||
import { api } from '../../api/client';
|
||||
import type { Report } from '../../types';
|
||||
|
||||
const TABS = ['ALL', 'OPEN', 'REVIEWING', 'RESOLVED', 'DISMISSED'];
|
||||
|
||||
export function AdminReportsPage() {
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [tab, setTab] = useState('ALL');
|
||||
const [selected, setSelected] = useState<Report | null>(null);
|
||||
const [resolution, setResolution] = useState('');
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
const res = await api.get<{ data: Report[]; total: number }>(`/admin/reports?${params}`);
|
||||
setReports(res.data);
|
||||
setTotal(res.total);
|
||||
}, [page, tab]);
|
||||
|
||||
useEffect(() => { fetchReports(); }, [fetchReports]);
|
||||
|
||||
const handleResolve = async (status: 'RESOLVED' | 'DISMISSED') => {
|
||||
if (!selected) return;
|
||||
await api.patch(`/admin/reports/${selected.id}`, { status, resolution: resolution || undefined });
|
||||
setSelected(null);
|
||||
setResolution('');
|
||||
fetchReports();
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const v = status === 'OPEN' ? 'warning' : status === 'REVIEWING' ? 'info' : status === 'RESOLVED' ? 'success' : 'default';
|
||||
return <Badge variant={v} size="sm">{status}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Reports</h1>
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-primary-100 text-primary-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'targetType', header: 'Type', render: (r: Report) => <Badge variant="default" size="sm">{r.targetType}</Badge> },
|
||||
{ key: 'reason', header: 'Reason', render: (r: Report) => r.reason.replace('_', ' ') },
|
||||
{ key: 'reporter', header: 'Reporter', render: (r: Report) => r.reporter?.fullName || 'Unknown' },
|
||||
{ key: 'status', header: 'Status', render: (r: Report) => statusBadge(r.status) },
|
||||
{ key: 'createdAt', header: 'Date', render: (r: Report) => new Date(r.createdAt).toLocaleDateString() },
|
||||
]}
|
||||
data={reports}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
actions={(r: Report) =>
|
||||
r.status === 'OPEN' || r.status === 'REVIEWING' ? (
|
||||
<button onClick={() => setSelected(r)} className="text-sm text-primary-600 hover:text-primary-700 font-medium cursor-pointer">
|
||||
Review
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal isOpen={!!selected} onClose={() => setSelected(null)} title="Review Report" size="sm">
|
||||
{selected && (
|
||||
<>
|
||||
<div className="space-y-2 mb-4 text-sm">
|
||||
<p><span className="font-medium">Type:</span> {selected.targetType}</p>
|
||||
<p><span className="font-medium">Reason:</span> {selected.reason}</p>
|
||||
{selected.description && <p><span className="font-medium">Details:</span> {selected.description}</p>}
|
||||
</div>
|
||||
<textarea
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Resolution notes (optional)..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:outline-none resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => handleResolve('DISMISSED')}>Dismiss</Button>
|
||||
<GradientButton className="flex-1" onClick={() => handleResolve('RESOLVED')}>Resolve</GradientButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
client/src/pages/admin/AdminSettingsPage.tsx
Normal file
188
client/src/pages/admin/AdminSettingsPage.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { GradientButton } from '../../components/ui/GradientButton';
|
||||
import { Input } from '../../components/ui/Input';
|
||||
import { api } from '../../api/client';
|
||||
import type { PlatformConfig } from '../../types';
|
||||
|
||||
export function AdminSettingsPage() {
|
||||
const [config, setConfig] = useState<PlatformConfig | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [keywordsText, setKeywordsText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
api.get<PlatformConfig>('/admin/settings').then((c) => {
|
||||
setConfig(c);
|
||||
setKeywordsText(c.blockedKeywords.join(', '));
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!config) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const keywords = keywordsText.split(',').map(k => k.trim()).filter(Boolean);
|
||||
const updated = await api.patch<PlatformConfig>('/admin/settings', { ...config, blockedKeywords: keywords });
|
||||
setConfig(updated);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
} catch {} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!config) return <div className="text-center text-gray-400 py-12">Loading settings...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Platform Settings</h1>
|
||||
<GradientButton onClick={handleSave} disabled={saving}>
|
||||
<Save className="w-4 h-4 mr-2" /> {saved ? 'Saved!' : saving ? 'Saving...' : 'Save Changes'}
|
||||
</GradientButton>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Fees & Commission</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Listing Fee ($)"
|
||||
type="number"
|
||||
value={String(config.listingFee)}
|
||||
onChange={(e) => setConfig({ ...config, listingFee: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="Commission (%)"
|
||||
type="number"
|
||||
value={String(config.commissionPercent)}
|
||||
onChange={(e) => setConfig({ ...config, commissionPercent: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="Promotion Day Price ($)"
|
||||
type="number"
|
||||
value={String(config.promotionDayPrice)}
|
||||
onChange={(e) => setConfig({ ...config, promotionDayPrice: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Subscription Pricing</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Pro Plan ($/month)"
|
||||
type="number"
|
||||
value={String(config.proPrice)}
|
||||
onChange={(e) => setConfig({ ...config, proPrice: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
<Input
|
||||
label="Business Plan ($/month)"
|
||||
type="number"
|
||||
value={String(config.businessPrice)}
|
||||
onChange={(e) => setConfig({ ...config, businessPrice: parseFloat(e.target.value) || 0 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Listing Limits</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Max Images Per Listing"
|
||||
type="number"
|
||||
value={String(config.maxImagesPerListing)}
|
||||
onChange={(e) => setConfig({ ...config, maxImagesPerListing: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
<Input
|
||||
label="Max Listings (Free Tier)"
|
||||
type="number"
|
||||
value={String(config.maxListingsFreeTier)}
|
||||
onChange={(e) => setConfig({ ...config, maxListingsFreeTier: parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Rental Settings</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Rental Commission (%)"
|
||||
type="number"
|
||||
value={String((config as any).rentalCommissionPercent ?? 10)}
|
||||
onChange={(e) => setConfig({ ...config, rentalCommissionPercent: parseFloat(e.target.value) || 0 } as any)}
|
||||
/>
|
||||
<Input
|
||||
label="Max Rental Images Per Listing"
|
||||
type="number"
|
||||
value={String((config as any).maxRentalImagesPerListing ?? 10)}
|
||||
onChange={(e) => setConfig({ ...config, maxRentalImagesPerListing: parseInt(e.target.value) || 1 } as any)}
|
||||
/>
|
||||
<Input
|
||||
label="Booking Expiry Hours"
|
||||
type="number"
|
||||
value={String((config as any).bookingExpiryHours ?? 48)}
|
||||
onChange={(e) => setConfig({ ...config, bookingExpiryHours: parseInt(e.target.value) || 48 } as any)}
|
||||
/>
|
||||
<Input
|
||||
label="Rental Promotion Day Price ($)"
|
||||
type="number"
|
||||
value={String((config as any).rentalPromotionDayPrice ?? 3.99)}
|
||||
onChange={(e) => setConfig({ ...config, rentalPromotionDayPrice: parseFloat(e.target.value) || 0 } as any)}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Auto-Approve Rentals</p>
|
||||
<p className="text-xs text-gray-400">Rental listings go live immediately when enabled</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, rentalAutoApprove: !(config as any).rentalAutoApprove } as any)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
|
||||
(config as any).rentalAutoApprove ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
(config as any).rentalAutoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Moderation</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Auto-Approve Listings</p>
|
||||
<p className="text-xs text-gray-400">Listings go live immediately when enabled</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setConfig({ ...config, autoApprove: !config.autoApprove })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors cursor-pointer ${
|
||||
config.autoApprove ? 'bg-primary-600' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
config.autoApprove ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1.5">Blocked Keywords</label>
|
||||
<textarea
|
||||
value={keywordsText}
|
||||
onChange={(e) => setKeywordsText(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Enter keywords separated by commas..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Listings containing these words will be sent for review</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
client/src/pages/admin/AdminUserDetailPage.tsx
Normal file
195
client/src/pages/admin/AdminUserDetailPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Shield, Ban, CheckCircle } from 'lucide-react';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { GradientButton } from '../../components/ui/GradientButton';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Avatar } from '../../components/ui/Avatar';
|
||||
import { Modal } from '../../components/ui/Modal';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import type { ModerationLog } from '../../types';
|
||||
|
||||
interface UserDetail {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
phone?: string;
|
||||
location?: string;
|
||||
bio?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
role: string;
|
||||
isBanned: boolean;
|
||||
banReason?: string;
|
||||
bannedAt?: string;
|
||||
createdAt: string;
|
||||
_count: { listings: number; sentOffers: number; receivedOffers: number; reports: number };
|
||||
}
|
||||
|
||||
export function AdminUserDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isAdmin, isSuperAdmin } = useAuth();
|
||||
const [user, setUser] = useState<UserDetail | null>(null);
|
||||
const [logs, setLogs] = useState<ModerationLog[]>([]);
|
||||
const [showBan, setShowBan] = useState(false);
|
||||
const [showRole, setShowRole] = useState(false);
|
||||
const [banReason, setBanReason] = useState('');
|
||||
const [newRole, setNewRole] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
api.get<{ user: UserDetail; moderationLogs: ModerationLog[] }>(`/admin/users/${id}`)
|
||||
.then(({ user, moderationLogs }) => {
|
||||
setUser(user);
|
||||
setLogs(moderationLogs);
|
||||
setNewRole(user.role);
|
||||
})
|
||||
.catch(() => navigate('/admin/users'));
|
||||
}, [id, navigate]);
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!id || !banReason) return;
|
||||
await api.post(`/admin/users/${id}/ban`, { reason: banReason });
|
||||
setUser(prev => prev ? { ...prev, isBanned: true, banReason } : null);
|
||||
setShowBan(false);
|
||||
setBanReason('');
|
||||
};
|
||||
|
||||
const handleUnban = async () => {
|
||||
if (!id) return;
|
||||
await api.post(`/admin/users/${id}/unban`);
|
||||
setUser(prev => prev ? { ...prev, isBanned: false, banReason: undefined } : null);
|
||||
};
|
||||
|
||||
const handleRoleChange = async () => {
|
||||
if (!id || !newRole) return;
|
||||
await api.patch(`/admin/users/${id}/role`, { role: newRole });
|
||||
setUser(prev => prev ? { ...prev, role: newRole } : null);
|
||||
setShowRole(false);
|
||||
};
|
||||
|
||||
if (!user) return <div className="text-center text-gray-400 py-12">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => navigate('/admin/users')} className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700 mb-4 cursor-pointer">
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Users
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={user.fullName} src={user.avatar} size="lg" />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{user.fullName}</h1>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Badge variant={user.role === 'SUPER_ADMIN' ? 'error' : user.role === 'ADMIN' ? 'warning' : user.role === 'MODERATOR' ? 'info' : 'default'} size="sm">
|
||||
{user.role}
|
||||
</Badge>
|
||||
{user.isBanned && <Badge variant="error" size="sm">Banned</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{isAdmin && !user.isBanned && (
|
||||
<Button variant="danger" size="sm" onClick={() => setShowBan(true)}>
|
||||
<Ban className="w-4 h-4 mr-1" /> Ban
|
||||
</Button>
|
||||
)}
|
||||
{isAdmin && user.isBanned && (
|
||||
<Button variant="outline" size="sm" onClick={handleUnban}>
|
||||
<CheckCircle className="w-4 h-4 mr-1" /> Unban
|
||||
</Button>
|
||||
)}
|
||||
{isSuperAdmin && (
|
||||
<Button variant="outline" size="sm" onClick={() => setShowRole(true)}>
|
||||
<Shield className="w-4 h-4 mr-1" /> Change Role
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6">
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-lg font-bold">{user._count.listings}</p>
|
||||
<p className="text-xs text-gray-500">Listings</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-lg font-bold">{user._count.sentOffers}</p>
|
||||
<p className="text-xs text-gray-500">Offers Sent</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-lg font-bold">{user._count.receivedOffers}</p>
|
||||
<p className="text-xs text-gray-500">Offers Received</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<p className="text-lg font-bold">{user._count.reports}</p>
|
||||
<p className="text-xs text-gray-500">Reports Filed</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-semibold text-gray-900 mb-4">Moderation History</h2>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No moderation history</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{logs.map((log) => (
|
||||
<div key={log.id} className="flex items-start gap-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{log.action}</p>
|
||||
{log.reason && <p className="text-xs text-gray-500 mt-0.5">{log.reason}</p>}
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
by {log.moderator?.fullName} on {new Date(log.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ban Modal */}
|
||||
<Modal isOpen={showBan} onClose={() => setShowBan(false)} title="Ban User" size="sm">
|
||||
<p className="text-sm text-gray-500 mb-4">Provide a reason for banning {user.fullName}.</p>
|
||||
<textarea
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Reason for ban..."
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm placeholder:text-gray-400 focus:border-primary-400 focus:ring-2 focus:ring-primary-100 focus:outline-none resize-none mb-4"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setShowBan(false)}>Cancel</Button>
|
||||
<Button variant="danger" className="flex-1" onClick={handleBan} disabled={!banReason}>Confirm Ban</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Role Modal */}
|
||||
<Modal isOpen={showRole} onClose={() => setShowRole(false)} title="Change Role" size="sm">
|
||||
<div className="mb-4">
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm focus:border-primary-400 focus:outline-none"
|
||||
>
|
||||
<option value="USER">User</option>
|
||||
<option value="MODERATOR">Moderator</option>
|
||||
<option value="ADMIN">Admin</option>
|
||||
<option value="SUPER_ADMIN">Super Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="secondary" className="flex-1" onClick={() => setShowRole(false)}>Cancel</Button>
|
||||
<GradientButton className="flex-1" onClick={handleRoleChange}>Save</GradientButton>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
client/src/pages/admin/AdminUsersPage.tsx
Normal file
89
client/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Avatar } from '../../components/ui/Avatar';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
interface AdminUser {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
isBanned: boolean;
|
||||
createdAt: string;
|
||||
_count: { listings: number; sentOffers: number; reports: number };
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
const res = await api.get<{ data: AdminUser[]; total: number }>(`/admin/users?${params}`);
|
||||
setUsers(res.data);
|
||||
setTotal(res.total);
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => { fetchUsers(); }, [fetchUsers]);
|
||||
|
||||
const roleBadge = (role: string) => {
|
||||
const variant = role === 'SUPER_ADMIN' ? 'error' : role === 'ADMIN' ? 'warning' : role === 'MODERATOR' ? 'info' : 'default';
|
||||
return <Badge variant={variant} size="sm">{role}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Users</h1>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'user',
|
||||
header: 'User',
|
||||
render: (u: AdminUser) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar name={u.fullName} src={u.avatar} size="sm" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{u.fullName}</p>
|
||||
<p className="text-xs text-gray-400">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'role', header: 'Role', render: (u: AdminUser) => roleBadge(u.role) },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (u: AdminUser) => u.isBanned
|
||||
? <Badge variant="error" size="sm">Banned</Badge>
|
||||
: <Badge variant="success" size="sm">Active</Badge>,
|
||||
},
|
||||
{ key: 'listings', header: 'Listings', render: (u: AdminUser) => u._count.listings },
|
||||
{ key: 'createdAt', header: 'Joined', render: (u: AdminUser) => new Date(u.createdAt).toLocaleDateString() },
|
||||
]}
|
||||
data={users}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search users..."
|
||||
actions={(u: AdminUser) => (
|
||||
<button
|
||||
onClick={() => navigate(`/admin/users/${u.id}`)}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 font-medium cursor-pointer"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
176
client/src/pages/landlord/LandlordBookingsPage.tsx
Normal file
176
client/src/pages/landlord/LandlordBookingsPage.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Check, X, CheckCircle } from 'lucide-react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { api } from '../../api/client';
|
||||
import type { Booking } from '../../types/rental';
|
||||
|
||||
const TABS = ['ALL', 'PENDING', 'ACTIVE', 'COMPLETED'] as const;
|
||||
|
||||
export function LandlordBookingsPage() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [tab, setTab] = useState<string>('ALL');
|
||||
|
||||
const fetchBookings = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ role: 'landlord', page: String(page), pageSize: '20' });
|
||||
if (tab !== 'ALL') params.set('status', tab);
|
||||
const res = await api.get<Booking[]>(`/bookings?${params}`);
|
||||
setBookings(res);
|
||||
setTotal(res.length);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [page, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBookings();
|
||||
}, [fetchBookings]);
|
||||
|
||||
const handleAction = async (bookingId: string, action: 'confirm' | 'reject' | 'complete') => {
|
||||
try {
|
||||
if (action === 'reject') {
|
||||
const reason = window.prompt('Reason for rejection:');
|
||||
if (!reason) return;
|
||||
await api.patch(`/bookings/${bookingId}/${action}`, { reason });
|
||||
} else {
|
||||
await api.patch(`/bookings/${bookingId}/${action}`);
|
||||
}
|
||||
fetchBookings();
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
|
||||
PENDING: 'warning',
|
||||
CONFIRMED: 'info',
|
||||
ACTIVE: 'success',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED_BY_TENANT: 'error',
|
||||
CANCELLED_BY_LANDLORD: 'error',
|
||||
REJECTED: 'error',
|
||||
EXPIRED: 'default',
|
||||
};
|
||||
return <Badge variant={map[status] || 'default'} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Bookings</h1>
|
||||
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => { setTab(t); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||
tab === t ? 'bg-violet-100 text-violet-700' : 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'listing',
|
||||
header: 'Listing',
|
||||
render: (b: Booking) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{b.rentalListing.images[0] ? (
|
||||
<img src={b.rentalListing.images[0].url} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{b.rentalListing.title}</p>
|
||||
<p className="text-xs text-gray-400">{b.rentalListing.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tenant',
|
||||
header: 'Tenant',
|
||||
render: (b: Booking) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{b.tenant.avatar ? (
|
||||
<img src={b.tenant.avatar} className="w-6 h-6 rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-violet-100 flex items-center justify-center text-xs font-medium text-violet-700">
|
||||
{b.tenant.fullName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-sm">{b.tenant.fullName}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Dates',
|
||||
render: (b: Booking) => (
|
||||
<div className="text-sm">
|
||||
<div>{new Date(b.startDate).toLocaleDateString()}</div>
|
||||
<div className="text-gray-400">to {new Date(b.endDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Amount',
|
||||
render: (b: Booking) => <span className="font-medium">${b.totalAmount.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (b: Booking) => statusBadge(b.status),
|
||||
},
|
||||
]}
|
||||
data={bookings}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
actions={(b: Booking) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
{b.status === 'PENDING' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleAction(b.id, 'confirm')}
|
||||
className="p-1.5 rounded-lg hover:bg-green-50 text-gray-500 hover:text-green-600 cursor-pointer"
|
||||
title="Confirm"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(b.id, 'reject')}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600 cursor-pointer"
|
||||
title="Reject"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(b.status === 'CONFIRMED' || b.status === 'ACTIVE') && (
|
||||
<button
|
||||
onClick={() => handleAction(b.id, 'complete')}
|
||||
className="p-1.5 rounded-lg hover:bg-green-50 text-gray-500 hover:text-green-600 cursor-pointer"
|
||||
title="Complete"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
272
client/src/pages/landlord/LandlordCalendarPage.tsx
Normal file
272
client/src/pages/landlord/LandlordCalendarPage.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Lock } from 'lucide-react';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { api } from '../../api/client';
|
||||
import type { Booking, RentalListing, AvailabilityBlock } from '../../types/rental';
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
return new Date(year, month + 1, 0).getDate();
|
||||
}
|
||||
|
||||
function getFirstDayOfMonth(year: number, month: number) {
|
||||
return new Date(year, month, 1).getDay();
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
];
|
||||
|
||||
export function LandlordCalendarPage() {
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth());
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [blocks, setBlocks] = useState<AvailabilityBlock[]>([]);
|
||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||
const [selectedRental, setSelectedRental] = useState<string>('all');
|
||||
const [blockingStart, setBlockingStart] = useState<string | null>(null);
|
||||
const [blockingEnd, setBlockingEnd] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [rentalsRes, bookingsRes] = await Promise.all([
|
||||
api.get<RentalListing[]>('/rentals/mine'),
|
||||
api.get<Booking[]>('/bookings?role=landlord'),
|
||||
]);
|
||||
setRentals(rentalsRes);
|
||||
setBookings(bookingsRes);
|
||||
|
||||
const allBlocks: AvailabilityBlock[] = [];
|
||||
for (const rental of rentalsRes) {
|
||||
try {
|
||||
const res = await api.get<{ blocks: AvailabilityBlock[]; bookings: unknown[] }>(`/rentals/${rental.id}/availability`);
|
||||
allBlocks.push(...res.blocks);
|
||||
} catch {
|
||||
// skip if endpoint not available
|
||||
}
|
||||
}
|
||||
setBlocks(allBlocks);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const filteredBookings = useMemo(() => {
|
||||
if (selectedRental === 'all') return bookings;
|
||||
return bookings.filter((b) => b.rentalListingId === selectedRental);
|
||||
}, [bookings, selectedRental]);
|
||||
|
||||
const filteredBlocks = useMemo(() => {
|
||||
if (selectedRental === 'all') return blocks;
|
||||
return blocks.filter((b) => b.rentalListingId === selectedRental);
|
||||
}, [blocks, selectedRental]);
|
||||
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstDay = getFirstDayOfMonth(year, month);
|
||||
|
||||
const getDateStr = (day: number) => {
|
||||
return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const getBookingsForDay = (day: number) => {
|
||||
const dateStr = getDateStr(day);
|
||||
return filteredBookings.filter((b) => {
|
||||
const start = b.startDate.slice(0, 10);
|
||||
const end = b.endDate.slice(0, 10);
|
||||
return dateStr >= start && dateStr <= end;
|
||||
});
|
||||
};
|
||||
|
||||
const isBlockedDay = (day: number) => {
|
||||
const dateStr = getDateStr(day);
|
||||
return filteredBlocks.some((b) => {
|
||||
const start = b.startDate.slice(0, 10);
|
||||
const end = b.endDate.slice(0, 10);
|
||||
return dateStr >= start && dateStr <= end && b.isBlocked;
|
||||
});
|
||||
};
|
||||
|
||||
const isInBlockingRange = (day: number) => {
|
||||
if (!blockingStart) return false;
|
||||
const dateStr = getDateStr(day);
|
||||
if (!blockingEnd) return dateStr === blockingStart;
|
||||
const start = blockingStart < blockingEnd ? blockingStart : blockingEnd;
|
||||
const end = blockingStart < blockingEnd ? blockingEnd : blockingStart;
|
||||
return dateStr >= start && dateStr <= end;
|
||||
};
|
||||
|
||||
const handleDayClick = (day: number) => {
|
||||
const dateStr = getDateStr(day);
|
||||
if (!blockingStart) {
|
||||
setBlockingStart(dateStr);
|
||||
setBlockingEnd(null);
|
||||
} else if (!blockingEnd) {
|
||||
setBlockingEnd(dateStr);
|
||||
} else {
|
||||
setBlockingStart(dateStr);
|
||||
setBlockingEnd(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockDates = async () => {
|
||||
if (!blockingStart || !blockingEnd || selectedRental === 'all') return;
|
||||
const start = blockingStart < blockingEnd ? blockingStart : blockingEnd;
|
||||
const end = blockingStart < blockingEnd ? blockingEnd : blockingStart;
|
||||
try {
|
||||
await api.post(`/rentals/${selectedRental}/availability`, {
|
||||
startDate: start,
|
||||
endDate: end,
|
||||
isBlocked: true,
|
||||
reason: 'Blocked by landlord',
|
||||
});
|
||||
const res = await api.get<{ blocks: AvailabilityBlock[]; bookings: unknown[] }>(`/rentals/${selectedRental}/availability`);
|
||||
setBlocks((prev) => [
|
||||
...prev.filter((b) => b.rentalListingId !== selectedRental),
|
||||
...res.blocks,
|
||||
]);
|
||||
setBlockingStart(null);
|
||||
setBlockingEnd(null);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const prevMonth = () => {
|
||||
if (month === 0) {
|
||||
setMonth(11);
|
||||
setYear(year - 1);
|
||||
} else {
|
||||
setMonth(month - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
if (month === 11) {
|
||||
setMonth(0);
|
||||
setYear(year + 1);
|
||||
} else {
|
||||
setMonth(month + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Calendar</h1>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<select
|
||||
value={selectedRental}
|
||||
onChange={(e) => setSelectedRental(e.target.value)}
|
||||
className="px-3 py-2 rounded-xl border border-gray-200 text-sm focus:border-violet-400 focus:ring-2 focus:ring-violet-100 focus:outline-none"
|
||||
>
|
||||
<option value="all">All Rentals</option>
|
||||
{rentals.map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{blockingStart && blockingEnd && selectedRental !== 'all' && (
|
||||
<Button variant="primary" size="sm" onClick={handleBlockDates}>
|
||||
<Lock className="w-3.5 h-3.5 mr-1.5" />
|
||||
Block Selected Dates
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||
{/* Month navigation */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-gray-100 cursor-pointer">
|
||||
<ChevronLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
{MONTH_NAMES[month]} {year}
|
||||
</h2>
|
||||
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-gray-100 cursor-pointer">
|
||||
<ChevronRight className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day headers */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-gray-400 py-2">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{Array.from({ length: firstDay }).map((_, i) => (
|
||||
<div key={`empty-${i}`} className="h-20 rounded-lg" />
|
||||
))}
|
||||
{Array.from({ length: daysInMonth }).map((_, i) => {
|
||||
const day = i + 1;
|
||||
const dayBookings = getBookingsForDay(day);
|
||||
const blocked = isBlockedDay(day);
|
||||
const inRange = isInBlockingRange(day);
|
||||
const isToday =
|
||||
day === today.getDate() && month === today.getMonth() && year === today.getFullYear();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day}
|
||||
onClick={() => handleDayClick(day)}
|
||||
className={`h-20 rounded-lg p-1.5 text-sm cursor-pointer transition-colors border ${
|
||||
inRange
|
||||
? 'border-violet-300 bg-violet-50'
|
||||
: blocked
|
||||
? 'border-red-200 bg-red-50'
|
||||
: isToday
|
||||
? 'border-violet-200 bg-violet-50/50'
|
||||
: 'border-transparent hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-medium ${isToday ? 'text-violet-700' : 'text-gray-700'}`}>
|
||||
{day}
|
||||
</span>
|
||||
{blocked && (
|
||||
<div className="mt-0.5">
|
||||
<Badge variant="error" size="sm">Blocked</Badge>
|
||||
</div>
|
||||
)}
|
||||
{dayBookings.slice(0, 2).map((b) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="mt-0.5 text-xs px-1 py-0.5 rounded bg-violet-100 text-violet-700 truncate"
|
||||
title={b.rentalListing.title}
|
||||
>
|
||||
{b.tenant.fullName.split(' ')[0]}
|
||||
</div>
|
||||
))}
|
||||
{dayBookings.length > 2 && (
|
||||
<span className="text-xs text-gray-400">+{dayBookings.length - 2}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex items-center gap-4 mt-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-violet-100 border border-violet-200" />
|
||||
<span>Booked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-red-50 border border-red-200" />
|
||||
<span>Blocked</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-3 h-3 rounded bg-violet-50 border border-violet-300" />
|
||||
<span>Selected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
client/src/pages/landlord/LandlordDashboardPage.tsx
Normal file
113
client/src/pages/landlord/LandlordDashboardPage.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ShoppingBag, Calendar, DollarSign, Star } from 'lucide-react';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { api } from '../../api/client';
|
||||
import type { RentalListing, Booking } from '../../types/rental';
|
||||
|
||||
interface LandlordStats {
|
||||
totalRentals: number;
|
||||
activeBookings: number;
|
||||
revenue: number;
|
||||
avgRating: number;
|
||||
}
|
||||
|
||||
export function LandlordDashboardPage() {
|
||||
const [stats, setStats] = useState<LandlordStats | null>(null);
|
||||
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [rentals, bookings] = await Promise.all([
|
||||
api.get<RentalListing[]>('/rentals/mine'),
|
||||
api.get<Booking[]>('/bookings?role=landlord'),
|
||||
]);
|
||||
|
||||
const totalRentals = rentals.length;
|
||||
const activeBookings = bookings.filter(
|
||||
(b) => b.status === 'CONFIRMED' || b.status === 'ACTIVE'
|
||||
).length;
|
||||
const revenue = bookings
|
||||
.filter((b) => b.status === 'COMPLETED')
|
||||
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||
|
||||
const ratings = rentals.filter((r) => r.avgRating).map((r) => r.avgRating!);
|
||||
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||
|
||||
setStats({ totalRentals, activeBookings, revenue, avgRating });
|
||||
setRecentBookings(bookings.slice(0, 5));
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-12">Loading dashboard...</div>;
|
||||
}
|
||||
|
||||
const bookingStatusBadge = (status: string) => {
|
||||
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
|
||||
PENDING: 'warning',
|
||||
CONFIRMED: 'info',
|
||||
ACTIVE: 'success',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED_BY_TENANT: 'error',
|
||||
CANCELLED_BY_LANDLORD: 'error',
|
||||
REJECTED: 'error',
|
||||
EXPIRED: 'default',
|
||||
};
|
||||
return <Badge variant={map[status] || 'default'} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Landlord Dashboard</h1>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard icon={ShoppingBag} label="Total Rentals" value={stats?.totalRentals ?? 0} color="blue" />
|
||||
<StatCard icon={Calendar} label="Active Bookings" value={stats?.activeBookings ?? 0} color="green" />
|
||||
<StatCard icon={DollarSign} label="Revenue" value={`$${(stats?.revenue ?? 0).toFixed(2)}`} color="pink" />
|
||||
<StatCard icon={Star} label="Avg Rating" value={stats?.avgRating ? stats.avgRating.toFixed(1) : 'N/A'} color="yellow" />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Recent Bookings</h3>
|
||||
{recentBookings.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 py-4 text-center">No bookings yet</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentBookings.map((booking) => (
|
||||
<div key={booking.id} className="flex items-center justify-between p-3 rounded-xl bg-gray-50">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-200 overflow-hidden flex-shrink-0">
|
||||
{booking.rentalListing.images[0] ? (
|
||||
<img src={booking.rentalListing.images[0].url} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-300" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{booking.rentalListing.title}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{booking.tenant.fullName} · {new Date(booking.startDate).toLocaleDateString()} - {new Date(booking.endDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className="text-sm font-semibold text-gray-900">${booking.totalAmount.toFixed(2)}</span>
|
||||
{bookingStatusBadge(booking.status)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
client/src/pages/landlord/LandlordListingsPage.tsx
Normal file
156
client/src/pages/landlord/LandlordListingsPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Pencil, Pause, Play, Trash2 } from 'lucide-react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { api } from '../../api/client';
|
||||
import type { RentalListing } from '../../types/rental';
|
||||
|
||||
export function LandlordListingsPage() {
|
||||
const [listings, setListings] = useState<RentalListing[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fetchListings = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
if (search) params.set('search', search);
|
||||
const res = await api.get<RentalListing[]>(`/rentals/mine?${params}`);
|
||||
setListings(res);
|
||||
setTotal(res.length);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchListings();
|
||||
}, [fetchListings]);
|
||||
|
||||
const handleToggleStatus = async (listing: RentalListing) => {
|
||||
try {
|
||||
if (listing.status === 'ACTIVE') {
|
||||
await api.post(`/rentals/${listing.id}/pause`);
|
||||
} else {
|
||||
await api.post(`/rentals/${listing.id}/activate`);
|
||||
}
|
||||
fetchListings();
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (listing: RentalListing) => {
|
||||
if (!window.confirm(`Delete "${listing.title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await api.delete(`/rentals/${listing.id}`);
|
||||
fetchListings();
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
|
||||
ACTIVE: 'success',
|
||||
PAUSED: 'warning',
|
||||
PENDING_REVIEW: 'info',
|
||||
DRAFT: 'default',
|
||||
DELETED: 'error',
|
||||
};
|
||||
return <Badge variant={map[status] || 'default'} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">My Rentals</h1>
|
||||
<Link to="/rentals/new">
|
||||
<Button variant="primary" size="sm">+ New Rental</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'title',
|
||||
header: 'Listing',
|
||||
render: (l: RentalListing) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
{l.images[0] ? (
|
||||
<img src={l.images[0].url} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gray-200" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">{l.title}</p>
|
||||
<p className="text-xs text-gray-400">{l.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
render: (l: RentalListing) => l.category.replace(/_/g, ' '),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (l: RentalListing) => statusBadge(l.status),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
header: 'Price',
|
||||
render: (l: RentalListing) => (
|
||||
<div className="text-sm">
|
||||
{l.dailyPrice != null && <div>${l.dailyPrice}/day</div>}
|
||||
{l.monthlyPrice != null && <div>${l.monthlyPrice}/mo</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bookings',
|
||||
header: 'Bookings',
|
||||
render: (l: RentalListing) => l._count?.bookings ?? 0,
|
||||
},
|
||||
]}
|
||||
data={listings}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
searchValue={search}
|
||||
onSearch={(v) => { setSearch(v); setPage(1); }}
|
||||
searchPlaceholder="Search rentals..."
|
||||
actions={(l: RentalListing) => (
|
||||
<div className="flex items-center gap-1 justify-end">
|
||||
<Link to={`/rentals/${l.id}/edit`}>
|
||||
<button className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 cursor-pointer" title="Edit">
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(l)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||
title={l.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||
>
|
||||
{l.status === 'ACTIVE' ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(l)}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600 cursor-pointer"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
client/src/pages/landlord/LandlordPayoutsPage.tsx
Normal file
160
client/src/pages/landlord/LandlordPayoutsPage.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { DollarSign, ExternalLink } from 'lucide-react';
|
||||
import { DataTable } from '../../components/ui/DataTable';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { GradientButton } from '../../components/ui/GradientButton';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { api } from '../../api/client';
|
||||
import type { Payout } from '../../types/rental';
|
||||
|
||||
interface AccountStatus {
|
||||
connected: boolean;
|
||||
chargesEnabled: boolean;
|
||||
payoutsEnabled: boolean;
|
||||
onboardingUrl?: string;
|
||||
}
|
||||
|
||||
export function LandlordPayoutsPage() {
|
||||
const [payouts, setPayouts] = useState<Payout[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(null);
|
||||
const [connectLoading, setConnectLoading] = useState(false);
|
||||
|
||||
const fetchPayouts = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
|
||||
const res = await api.get<Payout[]>(`/payouts?${params}`);
|
||||
setPayouts(res);
|
||||
setTotal(res.length);
|
||||
} catch {
|
||||
// silently fail
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPayouts();
|
||||
api.get<AccountStatus>('/payouts/account-status').then(setAccountStatus).catch(() => {});
|
||||
}, [fetchPayouts]);
|
||||
|
||||
const handleSetupStripe = async () => {
|
||||
setConnectLoading(true);
|
||||
try {
|
||||
const { url } = await api.post<{ url: string }>('/payouts/setup-account');
|
||||
window.location.href = url;
|
||||
} catch {
|
||||
setConnectLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
|
||||
COMPLETED: 'success',
|
||||
PROCESSING: 'info',
|
||||
PENDING: 'warning',
|
||||
FAILED: 'error',
|
||||
};
|
||||
return <Badge variant={map[status] || 'default'} size="sm">{status}</Badge>;
|
||||
};
|
||||
|
||||
const totalEarned = payouts.filter((p) => p.status === 'COMPLETED').reduce((sum, p) => sum + p.netAmount, 0);
|
||||
const pendingAmount = payouts.filter((p) => p.status === 'PENDING' || p.status === 'PROCESSING').reduce((sum, p) => sum + p.netAmount, 0);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Payouts</h1>
|
||||
|
||||
{/* Stripe Connect status */}
|
||||
{accountStatus && !accountStatus.payoutsEnabled && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-5 mb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold text-amber-900 mb-1">
|
||||
{accountStatus.connected ? 'Complete Stripe Setup' : 'Set Up Payouts'}
|
||||
</h3>
|
||||
<p className="text-sm text-amber-700">
|
||||
{accountStatus.connected
|
||||
? 'Your Stripe account needs additional information before payouts can be processed.'
|
||||
: 'Connect your Stripe account to receive payouts for your rental bookings.'}
|
||||
</p>
|
||||
</div>
|
||||
<GradientButton size="sm" onClick={handleSetupStripe} isLoading={connectLoading}>
|
||||
<ExternalLink className="w-4 h-4 mr-1.5" />
|
||||
{accountStatus.connected ? 'Complete Setup' : 'Connect Stripe'}
|
||||
</GradientButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<StatCard icon={DollarSign} label="Total Earned" value={`$${totalEarned.toFixed(2)}`} color="green" />
|
||||
<StatCard icon={DollarSign} label="Pending" value={`$${pendingAmount.toFixed(2)}`} color="yellow" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'booking',
|
||||
header: 'Booking',
|
||||
render: (p: Payout) => (
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">
|
||||
{p.booking?.rentalListing?.title || 'Unknown Listing'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{p.booking?.tenant?.fullName || 'Unknown Tenant'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dates',
|
||||
header: 'Period',
|
||||
render: (p: Payout) => (
|
||||
<div className="text-sm">
|
||||
{p.booking ? (
|
||||
<>
|
||||
<div>{new Date(p.booking.startDate).toLocaleDateString()}</div>
|
||||
<div className="text-gray-400">to {new Date(p.booking.endDate).toLocaleDateString()}</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-gray-400">--</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'gross',
|
||||
header: 'Gross',
|
||||
render: (p: Payout) => <span className="text-sm">${p.grossAmount.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'commission',
|
||||
header: 'Commission',
|
||||
render: (p: Payout) => <span className="text-sm text-gray-500">-${p.commissionAmount.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'Net',
|
||||
render: (p: Payout) => <span className="font-medium text-green-700">${p.netAmount.toFixed(2)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (p: Payout) => statusBadge(p.status),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Date',
|
||||
render: (p: Payout) => new Date(p.createdAt).toLocaleDateString(),
|
||||
},
|
||||
]}
|
||||
data={payouts}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={20}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
client/src/pages/landlord/LandlordReviewsPage.tsx
Normal file
170
client/src/pages/landlord/LandlordReviewsPage.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Star, Send } from 'lucide-react';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { api } from '../../api/client';
|
||||
import type { RentalReview } from '../../types/rental';
|
||||
|
||||
function StarRating({ rating }: { rating: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-4 h-4 ${i < rating ? 'fill-yellow-400 text-yellow-400' : 'text-gray-200'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewCard({
|
||||
review,
|
||||
onRespond,
|
||||
}: {
|
||||
review: RentalReview;
|
||||
onRespond: (reviewId: string, response: string) => Promise<void>;
|
||||
}) {
|
||||
const [responding, setResponding] = useState(false);
|
||||
const [response, setResponse] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!response.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onRespond(review.id, response.trim());
|
||||
setResponding(false);
|
||||
setResponse('');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{review.tenant?.avatar ? (
|
||||
<img src={review.tenant.avatar} className="w-10 h-10 rounded-full object-cover" alt="" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-violet-100 flex items-center justify-center text-sm font-medium text-violet-700">
|
||||
{review.tenant?.fullName?.charAt(0) || '?'}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{review.tenant?.fullName || 'Unknown'}</p>
|
||||
<p className="text-xs text-gray-400">{new Date(review.createdAt).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<StarRating rating={review.rating} />
|
||||
</div>
|
||||
|
||||
{review.rentalListing && (
|
||||
<div className="mb-2">
|
||||
<Badge variant="info" size="sm">{review.rentalListing.title}</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{review.comment && (
|
||||
<p className="text-sm text-gray-700 mb-3">{review.comment}</p>
|
||||
)}
|
||||
|
||||
{review.landlordResponse ? (
|
||||
<div className="bg-violet-50 rounded-xl p-3 mt-3">
|
||||
<p className="text-xs font-medium text-violet-700 mb-1">Your Response</p>
|
||||
<p className="text-sm text-violet-900">{review.landlordResponse}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
{responding ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={response}
|
||||
onChange={(e) => setResponse(e.target.value)}
|
||||
placeholder="Write your response..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 rounded-xl border border-gray-200 text-sm focus:border-violet-400 focus:ring-2 focus:ring-violet-100 focus:outline-none resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="primary" size="sm" onClick={handleSubmit} isLoading={submitting}>
|
||||
<Send className="w-3.5 h-3.5 mr-1.5" />
|
||||
Submit
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setResponding(false); setResponse(''); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setResponding(true)}
|
||||
className="text-sm text-violet-600 hover:text-violet-700 font-medium cursor-pointer"
|
||||
>
|
||||
Respond to review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LandlordReviewsPage() {
|
||||
const { user } = useAuth();
|
||||
const [reviews, setReviews] = useState<RentalReview[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
api
|
||||
.get<{ reviews: RentalReview[]; avgRating: number; totalReviews: number }>(`/rental-reviews/landlord/${user.id}`)
|
||||
.then(res => setReviews(res.reviews))
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
|
||||
const handleRespond = async (reviewId: string, response: string) => {
|
||||
await api.patch(`/rental-reviews/${reviewId}/respond`, { response });
|
||||
setReviews((prev) =>
|
||||
prev.map((r) => (r.id === reviewId ? { ...r, landlordResponse: response } : r))
|
||||
);
|
||||
};
|
||||
|
||||
const avgRating =
|
||||
reviews.length > 0
|
||||
? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
|
||||
: 0;
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center text-gray-400 py-12">Loading reviews...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reviews</h1>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<StarRating rating={Math.round(avgRating)} />
|
||||
<span className="font-medium text-gray-900">{avgRating.toFixed(1)}</span>
|
||||
<span>({reviews.length} review{reviews.length !== 1 ? 's' : ''})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviews.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-12 text-center">
|
||||
<Star className="w-12 h-12 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-gray-500">No reviews yet</p>
|
||||
<p className="text-sm text-gray-400 mt-1">Reviews from tenants will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map((review) => (
|
||||
<ReviewCard key={review.id} review={review} onRespond={handleRespond} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,32 @@ import { SettingsPage } from './pages/SettingsPage';
|
||||
import { MyListingsPage } from './pages/MyListingsPage';
|
||||
import { SavedItemsPage } from './pages/SavedItemsPage';
|
||||
import { AboutPage, PrivacyPage, TermsPage, HelpPage, ContactPage, ReturnsPage } from './pages/StaticPages';
|
||||
import { RequireRole } from './components/layout/RequireRole';
|
||||
import { AdminLayout } from './components/layout/AdminLayout';
|
||||
import { AdminDashboardPage } from './pages/admin/AdminDashboardPage';
|
||||
import { AdminUsersPage } from './pages/admin/AdminUsersPage';
|
||||
import { AdminUserDetailPage } from './pages/admin/AdminUserDetailPage';
|
||||
import { AdminListingsPage } from './pages/admin/AdminListingsPage';
|
||||
import { AdminReportsPage } from './pages/admin/AdminReportsPage';
|
||||
import { AdminModerationPage } from './pages/admin/AdminModerationPage';
|
||||
import { AdminPaymentsPage } from './pages/admin/AdminPaymentsPage';
|
||||
import { AdminSettingsPage } from './pages/admin/AdminSettingsPage';
|
||||
// Rental imports
|
||||
import { RentalsPage } from './pages/RentalsPage';
|
||||
import { RentalDetailPage } from './pages/RentalDetailPage';
|
||||
import { CreateRentalPage } from './pages/CreateRentalPage';
|
||||
import { EditRentalPage } from './pages/EditRentalPage';
|
||||
import { MyBookingsPage } from './pages/MyBookingsPage';
|
||||
import { LandlordLayout } from './components/layout/LandlordLayout';
|
||||
import { LandlordDashboardPage } from './pages/landlord/LandlordDashboardPage';
|
||||
import { LandlordListingsPage } from './pages/landlord/LandlordListingsPage';
|
||||
import { LandlordBookingsPage } from './pages/landlord/LandlordBookingsPage';
|
||||
import { LandlordCalendarPage } from './pages/landlord/LandlordCalendarPage';
|
||||
import { LandlordPayoutsPage } from './pages/landlord/LandlordPayoutsPage';
|
||||
import { LandlordReviewsPage } from './pages/landlord/LandlordReviewsPage';
|
||||
import { AdminRentalsPage } from './pages/admin/AdminRentalsPage';
|
||||
import { AdminBookingsPage } from './pages/admin/AdminBookingsPage';
|
||||
import { AdminRentalPayoutsPage } from './pages/admin/AdminRentalPayoutsPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -36,6 +62,28 @@ export const router = createBrowserRouter([
|
||||
{ path: 'help', element: <HelpPage /> },
|
||||
{ path: 'contact', element: <ContactPage /> },
|
||||
{ path: 'returns', element: <ReturnsPage /> },
|
||||
// Rental routes
|
||||
{ path: 'rentals', element: <RentalsPage /> },
|
||||
{ path: 'rentals/new', element: <RequireAuth><CreateRentalPage /></RequireAuth> },
|
||||
{ path: 'rentals/:id', element: <RentalDetailPage /> },
|
||||
{ path: 'rentals/:id/edit', element: <RequireAuth><EditRentalPage /></RequireAuth> },
|
||||
{
|
||||
path: 'admin',
|
||||
element: <RequireRole roles={['MODERATOR', 'ADMIN', 'SUPER_ADMIN']}><AdminLayout /></RequireRole>,
|
||||
children: [
|
||||
{ index: true, element: <AdminDashboardPage /> },
|
||||
{ path: 'users', element: <AdminUsersPage /> },
|
||||
{ path: 'users/:id', element: <AdminUserDetailPage /> },
|
||||
{ path: 'listings', element: <AdminListingsPage /> },
|
||||
{ path: 'reports', element: <AdminReportsPage /> },
|
||||
{ path: 'moderation', element: <AdminModerationPage /> },
|
||||
{ path: 'payments', element: <AdminPaymentsPage /> },
|
||||
{ path: 'settings', element: <AdminSettingsPage /> },
|
||||
{ path: 'rentals', element: <AdminRentalsPage /> },
|
||||
{ path: 'bookings', element: <AdminBookingsPage /> },
|
||||
{ path: 'rental-payouts', element: <AdminRentalPayoutsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'dashboard',
|
||||
element: <RequireAuth><DashboardLayout /></RequireAuth>,
|
||||
@@ -47,6 +95,20 @@ export const router = createBrowserRouter([
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'listings', element: <MyListingsPage /> },
|
||||
{ path: 'saved', element: <SavedItemsPage /> },
|
||||
{ path: 'bookings', element: <MyBookingsPage /> },
|
||||
{ path: 'saved-rentals', element: <SavedItemsPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'landlord',
|
||||
element: <RequireAuth><LandlordLayout /></RequireAuth>,
|
||||
children: [
|
||||
{ index: true, element: <LandlordDashboardPage /> },
|
||||
{ path: 'listings', element: <LandlordListingsPage /> },
|
||||
{ path: 'bookings', element: <LandlordBookingsPage /> },
|
||||
{ path: 'calendar', element: <LandlordCalendarPage /> },
|
||||
{ path: 'payouts', element: <LandlordPayoutsPage /> },
|
||||
{ path: 'reviews', element: <LandlordReviewsPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,11 +11,11 @@ export type Category =
|
||||
|
||||
export type ListingCondition = 'NEW' | 'LIKE_NEW' | 'GENTLY_USED' | 'USED' | 'FAIR';
|
||||
|
||||
export type ListingStatus = 'DRAFT' | 'ACTIVE' | 'SOLD' | 'DELETED';
|
||||
export type ListingStatus = 'DRAFT' | 'ACTIVE' | 'PENDING_REVIEW' | 'SOLD' | 'DELETED';
|
||||
|
||||
export type OfferStatus = 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'COUNTERED' | 'CANCELLED' | 'EXPIRED';
|
||||
|
||||
export type NotificationType = 'NEW_OFFER' | 'OFFER_ACCEPTED' | 'OFFER_DECLINED' | 'ITEM_SOLD' | 'NEW_MESSAGE' | 'ITEM_FAVORITED';
|
||||
export type NotificationType = 'NEW_OFFER' | 'OFFER_ACCEPTED' | 'OFFER_DECLINED' | 'ITEM_SOLD' | 'NEW_MESSAGE' | 'ITEM_FAVORITED' | 'LISTING_APPROVED' | 'LISTING_REJECTED' | 'MODERATION_WARNING' | 'ACCOUNT_BANNED' | 'ACCOUNT_UNBANNED' | 'REPORT_RESOLVED' | 'BOOKING_REQUEST' | 'BOOKING_CONFIRMED' | 'BOOKING_REJECTED' | 'BOOKING_CANCELLED' | 'BOOKING_STARTED' | 'BOOKING_COMPLETED' | 'RENTAL_REVIEW' | 'PAYOUT_SENT' | 'PAYOUT_FAILED';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
@@ -29,6 +29,9 @@ export interface User {
|
||||
rating?: number;
|
||||
ratingCount?: number;
|
||||
createdAt: string;
|
||||
role?: string;
|
||||
isLandlord?: boolean;
|
||||
landlordVerified?: boolean;
|
||||
showEmail: boolean;
|
||||
showPhone: boolean;
|
||||
showLocation: boolean;
|
||||
@@ -118,3 +121,65 @@ export interface PaginatedResponse<T> {
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export type UserRole = 'USER' | 'MODERATOR' | 'ADMIN' | 'SUPER_ADMIN';
|
||||
|
||||
export type ReportReason = 'SPAM' | 'INAPPROPRIATE' | 'SCAM' | 'COUNTERFEIT' | 'PROHIBITED_ITEM' | 'HARASSMENT' | 'OTHER';
|
||||
export type ReportStatus = 'OPEN' | 'REVIEWING' | 'RESOLVED' | 'DISMISSED';
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
reporterId: string;
|
||||
targetType: 'LISTING' | 'USER';
|
||||
targetId: string;
|
||||
reason: ReportReason;
|
||||
description?: string;
|
||||
status: ReportStatus;
|
||||
resolvedBy?: string;
|
||||
resolution?: string;
|
||||
reporter?: User;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlatformConfig {
|
||||
id: string;
|
||||
listingFee: number;
|
||||
commissionPercent: number;
|
||||
autoApprove: boolean;
|
||||
maxImagesPerListing: number;
|
||||
maxListingsFreeTier: number;
|
||||
proPrice: number;
|
||||
businessPrice: number;
|
||||
promotionDayPrice: number;
|
||||
blockedKeywords: string[];
|
||||
rentalCommissionPercent: number;
|
||||
rentalAutoApprove: boolean;
|
||||
maxRentalImagesPerListing: number;
|
||||
bookingExpiryHours: number;
|
||||
rentalPromotionDayPrice: number;
|
||||
}
|
||||
|
||||
export type { RentalListing, Booking as RentalBooking, Payout, RentalReview, RentalStats, AvailabilityBlock, RentalCategory, BookingStatus as RentalBookingStatus, PayoutStatus, CancellationPolicy, RentalPeriodType, RentalListingStatus } from './rental';
|
||||
|
||||
export interface ModerationLog {
|
||||
id: string;
|
||||
moderatorId: string;
|
||||
targetUserId?: string;
|
||||
targetListingId?: string;
|
||||
action: string;
|
||||
reason?: string;
|
||||
details?: Record<string, unknown>;
|
||||
moderator?: User;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
totalUsers: number;
|
||||
totalListings: number;
|
||||
activeListings: number;
|
||||
pendingListings: number;
|
||||
totalOffers: number;
|
||||
totalRevenue: number;
|
||||
activeToday: number;
|
||||
}
|
||||
|
||||
157
client/src/types/rental.ts
Normal file
157
client/src/types/rental.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
export type RentalCategory = 'APARTMENT' | 'HOUSE' | 'CAR' | 'MOTORCYCLE' | 'BICYCLE' | 'EBIKE';
|
||||
|
||||
export type RentalPeriodType = 'DAILY' | 'MONTHLY';
|
||||
|
||||
export type RentalListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | 'PAUSED' | 'DELETED';
|
||||
|
||||
export type BookingStatus = 'PENDING' | 'CONFIRMED' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED_BY_TENANT' | 'CANCELLED_BY_LANDLORD' | 'REJECTED' | 'EXPIRED';
|
||||
|
||||
export type PayoutStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
export type CancellationPolicy = 'FLEXIBLE' | 'MODERATE' | 'STRICT';
|
||||
|
||||
export interface RentalImage {
|
||||
id: string;
|
||||
url: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface RentalListing {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: RentalCategory;
|
||||
location: string;
|
||||
dailyPrice?: number | null;
|
||||
monthlyPrice?: number | null;
|
||||
depositAmount?: number | null;
|
||||
details?: Record<string, unknown>;
|
||||
amenities: string[];
|
||||
rules: string[];
|
||||
cancellationPolicy: CancellationPolicy;
|
||||
minDays?: number | null;
|
||||
maxDays?: number | null;
|
||||
minMonths?: number | null;
|
||||
maxMonths?: number | null;
|
||||
status: RentalListingStatus;
|
||||
viewCount: number;
|
||||
isFeatured: boolean;
|
||||
isVerified: boolean;
|
||||
rejectionReason?: string | null;
|
||||
landlordId: string;
|
||||
landlord: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
rating?: number;
|
||||
location?: string;
|
||||
landlordVerified?: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
images: RentalImage[];
|
||||
isFavorited?: boolean;
|
||||
avgRating?: number;
|
||||
_count?: {
|
||||
favorites: number;
|
||||
bookings: number;
|
||||
reviews: number;
|
||||
};
|
||||
reviews?: RentalReview[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Booking {
|
||||
id: string;
|
||||
rentalListingId: string;
|
||||
tenantId: string;
|
||||
landlordId: string;
|
||||
periodType: RentalPeriodType;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
pricePerPeriod: number;
|
||||
totalPeriods: number;
|
||||
subtotal: number;
|
||||
commissionRate: number;
|
||||
commissionAmount: number;
|
||||
depositAmount: number;
|
||||
totalAmount: number;
|
||||
status: BookingStatus;
|
||||
message?: string;
|
||||
rejectionReason?: string;
|
||||
cancellationReason?: string;
|
||||
stripePaymentIntentId?: string;
|
||||
expiresAt?: string;
|
||||
rentalListing: {
|
||||
id: string;
|
||||
title: string;
|
||||
category: RentalCategory;
|
||||
location: string;
|
||||
cancellationPolicy: CancellationPolicy;
|
||||
images: RentalImage[];
|
||||
};
|
||||
tenant: { id: string; fullName: string; nickname?: string; avatar?: string };
|
||||
landlord: { id: string; fullName: string; nickname?: string; avatar?: string };
|
||||
payout?: Payout | null;
|
||||
review?: RentalReview | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Payout {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
landlordId: string;
|
||||
grossAmount: number;
|
||||
commissionAmount: number;
|
||||
netAmount: number;
|
||||
status: PayoutStatus;
|
||||
stripeTransferId?: string;
|
||||
booking?: {
|
||||
id: string;
|
||||
rentalListing: { id: string; title: string };
|
||||
tenant?: { id: string; fullName: string };
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RentalReview {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
rentalListingId: string;
|
||||
tenantId: string;
|
||||
landlordId: string;
|
||||
rating: number;
|
||||
comment?: string;
|
||||
landlordResponse?: string;
|
||||
tenant?: { id: string; fullName: string; avatar?: string };
|
||||
rentalListing?: { id: string; title: string };
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AvailabilityBlock {
|
||||
id: string;
|
||||
rentalListingId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
isBlocked: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RentalStats {
|
||||
totalRentals: number;
|
||||
activeRentals: number;
|
||||
pendingRentals: number;
|
||||
totalBookings: number;
|
||||
activeBookings: number;
|
||||
completedBookings: number;
|
||||
totalPayouts: number;
|
||||
pendingPayouts: number;
|
||||
totalRentalRevenue: number;
|
||||
totalPaidOut: number;
|
||||
}
|
||||
Reference in New Issue
Block a user