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:
delta-lynx-89e8
2026-02-22 15:33:29 -08:00
parent 8961fa701a
commit dbbbbd26f4
90 changed files with 11052 additions and 124 deletions

View File

@@ -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",

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

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

View File

@@ -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' },
];

View File

@@ -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>

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

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

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

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

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

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

View 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">&middot;</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>
);
}

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

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

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

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

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

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

View File

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

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

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

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

View File

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

View File

@@ -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>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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} &middot; {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>
);
}

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

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

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

View File

@@ -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 /> },
],
},
],

View File

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