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

View File

@@ -1,81 +1,214 @@
# Color Wheel Palette Generator
# Marketplace
An interactive color palette generator built with React, TypeScript, and Tailwind CSS v4. Pick a base color on a color wheel, choose a harmony type, adjust tint/shade, and export palettes in multiple formats.
## Features
- **Interactive Color Wheel** — Canvas-based HSL wheel with drag-to-select hue and saturation. Touch support for mobile.
- **7 Harmony Types** — Complementary, Split-Complementary, Analogous, Triadic, Tetradic, Square, Monochromatic.
- **Tint & Shade Slider** — Adjust lightness in real-time.
- **Color Input** — Enter HEX values directly, view RGB/HSL, adjust via RGB sliders.
- **Click-to-Copy** — Copy any color value (HEX, RGB, HSL) to clipboard.
- **Export** — CSS variables, JSON, Tailwind config snippet, or PNG image.
- **Dark / Light Theme** — Toggle between dark and light modes.
- **Responsive** — Mobile-first layout with horizontal scroll palette on small screens.
## Setup
```bash
npm install
npm run dev
```
Open `http://localhost:5173` in your browser.
## Build
```bash
npm run build
npm run preview
```
## Architecture
```
src/
├── components/ # UI components
│ ├── ColorWheel/ # Canvas-based color wheel with pointer events
│ ├── PaletteDisplay/ # Generated swatch grid
│ ├── HarmonySelector/ # Dropdown for harmony type
│ ├── ColorInput/ # HEX/RGB/HSL inputs and sliders
│ ├── TintShadeSlider/ # Lightness adjustment
│ ├── ExportPanel/ # CSS/JSON/Tailwind/PNG export
│ ├── Header/ # App header with theme toggle
│ └── MobileNav/ # Bottom sheet navigation for mobile
├── hooks/
│ ├── useColorWheel.ts # Wheel interaction (drag, position calculation)
│ └── useColorHarmony.ts # Harmony palette generation
├── utils/
│ ├── colorConversions.ts # HEX <-> RGB <-> HSL converters
│ └── harmonies.ts # Harmony algorithms
├── App.tsx # Main layout
├── main.tsx # Entry point
└── index.css # Tailwind directives + theme variables
```
## Harmony Algorithms
| Type | Colors Generated |
|---|---|
| Complementary | Base + 180° |
| Split-Complementary | Base, +150°, +210° |
| Analogous | -30°, Base, +30° |
| Triadic | Base, +120°, +240° |
| Tetradic | Base, +90°, +180°, +270° |
| Square | Base, +90°, +180°, +270° |
| Monochromatic | Same hue, varied S/L |
## MCP Integration
The project includes MCP server configuration in `.claude/settings.json`:
- **Figma MCP** — Connect to Figma to read design files and create new frames.
- **Chrome DevTools MCP** — Debug the running app, inspect elements, and validate layouts.
A full-stack online marketplace and rental platform where users can buy, sell, and rent items ranging from electronics and furniture to apartments, cars, and bicycles. The platform includes real-time chat, an offer/negotiation system, Stripe-powered payments, and a multi-role admin panel with content moderation.
## Tech Stack
- React 19 + TypeScript
- Vite
- Tailwind CSS v4
- HTML5 Canvas API
- Clipboard API
| Layer | Technology |
|----------------|------------------------------------------------------|
| **Client** | React 19, TypeScript, Tailwind CSS 4, Vite 6 |
| **Routing** | React Router 7 |
| **Server** | Express 4, TypeScript, tsx (dev runner) |
| **Database** | PostgreSQL 17 (Docker), Prisma ORM 6 |
| **Auth** | JWT (access + refresh tokens), bcryptjs |
| **Payments** | Stripe (PaymentIntents, Connect, Webhooks) |
| **Real-time** | Socket.io 4 (notifications, chat) |
| **File Upload**| Multer (local storage) |
| **Validation** | Zod |
| **Charts** | Recharts 3 (admin dashboard) |
| **Icons** | Lucide React |
## Features
- **Listings** -- Create, browse, search, and filter buy/sell listings across categories (Electronics, Furniture, Clothing, Vehicles, etc.)
- **Offers & Negotiation** -- Make offers, counter, accept, or decline with real-time notifications
- **Rentals** -- Full rental system for apartments, houses, cars, motorcycles, bicycles, and e-bikes with daily/monthly pricing, booking management, deposits, and cancellation policies
- **Chat** -- Real-time messaging between buyers/sellers and tenants/landlords via Socket.io
- **Payments** -- Stripe integration for listing fees, commissions, rental bookings, deposits, and landlord payouts via Stripe Connect
- **Subscriptions & Promotions** -- Tiered seller subscriptions (Basic, Pro, Business) and listing promotion/boosting
- **Notifications** -- Real-time in-app notifications for offers, messages, bookings, payouts, and moderation actions
- **Moderation** -- Content reporting, review queue, user banning, and moderation logs
- **Admin Panel** -- Dashboard with analytics, user management, listing management, and moderation tools
- **Auth & Roles** -- JWT authentication with four roles: `USER`, `MODERATOR`, `ADMIN`, `SUPER_ADMIN`
- **Location** -- Google Maps autocomplete for listing and rental locations
## Prerequisites
- **Node.js** >= 18
- **npm** >= 9 (uses npm workspaces)
- **Docker** (for PostgreSQL)
## Getting Started
### 1. Start the Database
If the Docker container already exists:
```bash
docker start marketplace-postgres
```
If you need to create it from scratch:
```bash
docker run -d \
--name marketplace-postgres \
-e POSTGRES_USER=marketplace \
-e POSTGRES_PASSWORD=marketplace_dev \
-e POSTGRES_DB=marketplace \
-p 5432:5432 \
-v marketplace-pgdata:/var/lib/postgresql/data \
--restart unless-stopped \
postgres:17-alpine
```
Verify it is running:
```bash
docker ps -f name=marketplace-postgres
```
### 2. Install Dependencies
From the project root:
```bash
npm install
```
This installs dependencies for both the `client` and `server` workspaces.
### 3. Configure Environment Variables
Create `server/.env`:
```env
DATABASE_URL=postgresql://marketplace:marketplace_dev@localhost:5432/marketplace
JWT_SECRET=your-secret-key
JWT_REFRESH_SECRET=your-refresh-secret-key
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
GOOGLE_MAPS_API_KEY=your-google-maps-key
CLIENT_URL=http://localhost:5173
UPLOAD_DIR=./uploads
PORT=3000
```
For local development, only `DATABASE_URL` is strictly required. Stripe keys are optional (payment features will be disabled). The server provides sensible defaults for all other values.
### 4. Set Up the Database Schema
```bash
cd server
npx prisma db push
```
Or use migrations:
```bash
cd server
npx prisma migrate dev
```
### 5. Seed the Database (Optional)
```bash
cd server
npx tsx prisma/seed.ts
```
### 6. Run the Application
Start both client and server simultaneously:
```bash
npm run dev
```
Or run them separately:
```bash
npm run dev:server # Express API on http://localhost:3000
npm run dev:client # React app on http://localhost:5173
```
## Seed Data
After seeding, the following test users are available (all use password `password123`):
| Email | Name |
|----------------------------|----------------|
| alice.chen@example.com | Alice Chen |
| bob.smith@example.com | Bob Smith |
| carol.jones@example.com | Carol Jones |
| david.wilson@example.com | David Wilson |
| eva.martinez@example.com | Eva Martinez |
## Project Structure
```
marketplace/
├── package.json # Root workspace config
├── CLAUDE.md # Dev notes (DB setup, commands)
├── docs/ # Documentation
│ ├── README.md # This file
│ └── architecture.md # Architecture overview
├── client/ # React frontend
│ ├── package.json
│ └── src/
│ ├── api/ # API client functions
│ ├── components/ # Reusable UI components
│ ├── context/ # React context providers
│ ├── pages/ # Route page components
│ ├── router.tsx # Route definitions
│ ├── types/ # TypeScript type definitions
│ ├── utils/ # Utility functions
│ ├── App.tsx # App root component
│ └── main.tsx # Entry point
└── server/ # Express backend
├── package.json
├── prisma/
│ ├── schema.prisma # Database schema
│ └── seed.ts # Seed script
└── src/
├── config/ # Environment config
├── middleware/ # Auth, upload, validation, error handling
├── routes/ # API route handlers
│ └── admin/ # Admin-specific routes
├── socket/ # Socket.io setup and handlers
├── utils/ # Shared utilities
├── validators/ # Zod validation schemas
└── index.ts # Server entry point
```
## Environment Variables Reference
| Variable | Required | Default | Description |
|---------------------------|----------|----------------------------------|------------------------------------|
| `DATABASE_URL` | Yes | localhost connection string | PostgreSQL connection URL |
| `PORT` | No | `3000` | Server port |
| `JWT_SECRET` | No* | Dev fallback | Access token signing secret |
| `JWT_REFRESH_SECRET` | No* | Dev fallback | Refresh token signing secret |
| `CLIENT_URL` | No | `http://localhost:5173` | CORS allowed origin |
| `UPLOAD_DIR` | No | `./uploads` | File upload directory |
| `STRIPE_SECRET_KEY` | No | Empty (payments disabled) | Stripe secret key |
| `STRIPE_PUBLISHABLE_KEY` | No | Empty (payments disabled) | Stripe publishable key |
| `STRIPE_WEBHOOK_SECRET` | No | Empty | Stripe webhook signing secret |
| `GOOGLE_MAPS_API_KEY` | No | Dev key | Google Maps API key |
*Must be set to secure values in production.
## Useful Commands
| Command | Description |
|---------------------------|------------------------------------------|
| `npm run dev` | Start client + server concurrently |
| `npm run dev:client` | Start React dev server (port 5173) |
| `npm run dev:server` | Start Express dev server (port 3000) |
| `npm run build` | Build client and server for production |
| `npm run lint` | Lint client code |
| `npx prisma studio` | Open Prisma Studio (DB browser) |
| `npx prisma db push` | Push schema changes to database |
| `npx prisma migrate dev` | Create and apply a migration |
| `npx tsx prisma/seed.ts` | Seed the database |

332
docs/architecture.md Normal file
View File

@@ -0,0 +1,332 @@
# Architecture
## High-Level Overview
```
┌──────────────────────────────────────────────────────────────┐
│ Client (React) │
│ http://localhost:5173 (Vite) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌────────────┐ │
│ │ Pages │ │Components│ │ Context │ │ API Layer │ │
│ └──────────┘ └──────────┘ └───────────┘ └─────┬──────┘ │
│ │ │
└────────────────────────────────────────────────────┼─────────┘
REST API (HTTP) │ WebSocket │
│ (Socket.io) │
┌──────────────────────────────────┼─────────────────┼─────────┐
│ Server (Express) │ │
│ http://localhost:3000 │ │
│ │ │
│ ┌───────────┐ ┌────────────┐ ┌──────────────┐ │ │
│ │ Middleware │ │ Routes │ │ Socket.io │◄─┘ │
│ │ (auth, │ │ (REST API) │ │ (real-time) │ │
│ │ upload, │ └─────┬──────┘ └──────┬───────┘ │
│ │ validate)│ │ │ │
│ └───────────┘ │ │ │
│ ┌────┴────────────────┴──────┐ │
│ │ Prisma ORM │ │
│ └────────────┬───────────────┘ │
│ │ │
└───────────────────────────────┼──────────────────────────────┘
┌───────────────────────────────┼──────────────────────────────┐
│ PostgreSQL 17 (Docker) │
│ marketplace-postgres container │
│ Volume: marketplace-pgdata │
└──────────────────────────────────────────────────────────────┘
External Services:
┌─────────┐ ┌──────────────┐
│ Stripe │ │ Google Maps │
│ (pay) │ │ (location) │
└─────────┘ └──────────────┘
```
## Client-Server Communication
### REST API
The client communicates with the server via a JSON REST API. All endpoints are prefixed with `/api/`.
| Prefix | Purpose |
|-----------------------|----------------------------------------------|
| `/api/auth` | Registration, login, token refresh, logout |
| `/api/users` | User profiles, settings, privacy |
| `/api/listings` | CRUD for buy/sell listings, search, favorites|
| `/api/offers` | Create, counter, accept, decline offers |
| `/api/chat` | Conversations and message history |
| `/api/notifications` | Fetch and mark notifications as read |
| `/api/payments` | Stripe payment intents, webhooks |
| `/api/location` | Google Maps autocomplete proxy |
| `/api/reports` | Content/user reporting |
| `/api/subscriptions` | Seller subscription management |
| `/api/promotions` | Listing promotion/boost |
| `/api/admin` | Admin dashboard, user/listing management |
| `/api/rentals` | Rental listing CRUD and search |
| `/api/bookings` | Booking lifecycle management |
| `/api/rental-payments`| Rental payment processing and webhooks |
| `/api/payouts` | Landlord payout management |
| `/api/rental-reviews` | Tenant/landlord reviews |
| `/api/health` | Health check endpoint |
### Socket.io (WebSocket)
Socket.io provides the real-time communication layer. The server creates a Socket.io instance attached to the same HTTP server as Express.
**Events flow:**
- The client connects with an auth token after login
- The server authenticates the socket connection and associates it with a user
- Real-time events are emitted for: new messages, notifications (offers, bookings, moderation), typing indicators, and online status
## Database Layer
### Prisma ORM
The database schema is defined in `server/prisma/schema.prisma`. Prisma generates a type-safe client used throughout the server routes.
**Key models:**
| Model | Purpose |
|-------------------|--------------------------------------------------|
| `User` | Accounts with roles, privacy settings, landlord status |
| `Session` | JWT refresh token sessions |
| `Listing` | Buy/sell items with category, condition, price |
| `ListingImage` | Image attachments for listings |
| `Offer` | Price negotiation between buyer and seller |
| `Conversation` | Chat threads between two users |
| `Message` | Individual chat messages |
| `Favorite` | User bookmarks on listings |
| `Notification` | In-app notifications (19 distinct types) |
| `Payment` | Payment records (8 payment types) |
| `Report` | Content/user reports for moderation |
| `Subscription` | Seller tier subscriptions |
| `PromotedListing` | Boosted listing placements |
| `ModerationLog` | Audit trail for moderation actions |
| `BlockedUser` | User blocking relationships |
| `RentalListing` | Rental items (apartments, cars, bikes, etc.) |
| `Booking` | Rental booking with status lifecycle |
| `RentalReview` | Reviews for completed rentals |
| `Payout` | Landlord payout tracking |
### Enums
The schema uses PostgreSQL enums extensively for type safety:
- **User roles:** `USER`, `MODERATOR`, `ADMIN`, `SUPER_ADMIN`
- **Listing categories:** `ELECTRONICS`, `FURNITURE`, `CLOTHING`, `HOME_GARDEN`, `SPORTS`, `BOOKS`, `GAMES`, `VEHICLES`, `OTHER`
- **Rental categories:** `APARTMENT`, `HOUSE`, `CAR`, `MOTORCYCLE`, `BICYCLE`, `EBIKE`
- **Booking status:** `PENDING` -> `CONFIRMED` -> `ACTIVE` -> `COMPLETED` (or `CANCELLED_*` / `REJECTED` / `EXPIRED`)
- **Cancellation policy:** `FLEXIBLE`, `MODERATE`, `STRICT`
## Authentication Flow
The application uses JWT-based authentication with access and refresh tokens.
```
┌────────┐ ┌────────┐
│ Client │ │ Server │
└───┬────┘ └───┬────┘
│ POST /api/auth/login │
│ { email, password } │
├──────────────────────────────────────►│
│ │ Verify password (bcryptjs)
│ │ Generate access token (short-lived)
│ │ Generate refresh token (long-lived)
│ │ Store session in DB
│ { accessToken, user } │
│ Set-Cookie: refreshToken (httpOnly) │
│◄──────────────────────────────────────┤
│ │
│ GET /api/listings │
│ Authorization: Bearer <accessToken> │
├──────────────────────────────────────►│
│ │ auth middleware verifies JWT
│ │ checkBanned middleware
│ { listings: [...] } │
│◄──────────────────────────────────────┤
│ │
│ POST /api/auth/refresh │
│ Cookie: refreshToken │
├──────────────────────────────────────►│
│ │ Validate refresh token
│ │ Issue new access token
│ { accessToken } │
│◄──────────────────────────────────────┤
```
### Middleware Chain
Requests pass through several middleware layers:
1. **`helmet`** -- Security headers
2. **`cors`** -- Restricts origins to `CLIENT_URL`
3. **`cookieParser`** -- Parses refresh token cookies
4. **`express-rate-limit`** -- Rate limits auth endpoints (20 requests / 15 min)
5. **`auth`** -- Verifies JWT access token, attaches user to request
6. **`checkBanned`** -- Blocks banned users
7. **`requireRole`** -- Role-based access control (e.g., admin-only routes)
8. **`upload`** -- Multer file upload handling
9. **`validate`** -- Zod schema validation for request bodies
## Payment Flow
### Marketplace Payments (Listings)
Stripe PaymentIntents are used for buy/sell transactions:
```
┌────────┐ ┌────────┐ ┌────────┐
│ Buyer │ │ Server │ │ Stripe │
└───┬────┘ └───┬────┘ └───┬────┘
│ Create PI │ │
├───────────────►│ POST /api/ │
│ │ payments │
│ ├───────────────►│ stripe.paymentIntents.create()
│ │ clientSecret │
│ │◄───────────────┤
│ clientSecret │ │
│◄───────────────┤ │
│ │ │
│ Confirm (Stripe.js) │
├────────────────────────────────►│
│ │ │
│ │ Webhook event │
│ │◄───────────────┤ payment_intent.succeeded
│ │ Update DB │
│ │ Notify seller │
│ │ │
```
### Rental Payments
Rental bookings follow a similar flow with additional handling for:
- **Security deposits** -- Held and refunded after completion
- **Landlord payouts** -- Via Stripe Connect (`stripeAccountId` on User model)
- **Commissions** -- Platform fee deducted from rental payments
- **Separate webhook endpoint** -- `/api/rental-payments/webhook`
### Payment Types
The system tracks eight distinct payment types:
| Type | Description |
|------------------------|-------------------------------------------|
| `LISTING_FEE` | Fee to publish a listing |
| `COMMISSION` | Platform commission on sales |
| `PROMOTION` | Listing boost/promotion fee |
| `SUBSCRIPTION` | Seller subscription payment |
| `RENTAL_BOOKING` | Tenant rental payment |
| `RENTAL_COMMISSION` | Platform fee on rentals |
| `RENTAL_DEPOSIT` | Security deposit hold |
| `RENTAL_DEPOSIT_REFUND`| Deposit refund after completion |
| `RENTAL_PAYOUT` | Payment to landlord via Connect |
## File Uploads
File uploads are handled by **Multer** with local disk storage.
- **Configuration:** `server/src/middleware/upload.ts`
- **Storage directory:** Configurable via `UPLOAD_DIR` env var (default: `./uploads`)
- **Serving:** Static files served at `/uploads/*` via `express.static`
- **Usage:** Listing images, rental listing photos, user avatars
Files are stored on the local filesystem. In production, this should be replaced with cloud storage (e.g., S3, Cloudflare R2).
## Real-Time Communication
Socket.io is configured in `server/src/socket/index.ts` and attached to the HTTP server alongside Express.
### Connection Flow
1. Client establishes WebSocket connection after successful login
2. Server authenticates the connection using the JWT token
3. User is registered as "online" and associated with their socket ID
### Event Types
| Category | Events |
|-----------------|--------------------------------------------------|
| **Chat** | New message, typing indicator, read receipts |
| **Notifications**| New offer, offer response, booking updates, payout status, moderation actions |
| **Presence** | User online/offline status |
### Architecture
```
Client A ──WebSocket──┐
├──► Socket.io Server ──► Emit to Client B
Client B ──WebSocket──┘ │
┌─────┴─────┐
│ Express │
│ routes │
│ can emit │
│ via io │
└───────────┘
```
Express routes access the Socket.io instance via `app.get('io')` to emit events from REST handlers (e.g., sending a notification when an offer is created via the REST API).
## Security
| Concern | Implementation |
|----------------------|---------------------------------------------------|
| HTTP headers | Helmet (XSS, clickjacking, MIME sniffing) |
| CORS | Restricted to `CLIENT_URL` |
| Rate limiting | 20 req/15 min on `/api/auth/login` and `/register`|
| Input validation | Zod schemas on all mutation endpoints |
| Password hashing | bcryptjs |
| Token storage | Refresh token in httpOnly cookie |
| Role-based access | `requireRole` middleware for admin/mod routes |
| User banning | `checkBanned` middleware on protected routes |
| Webhook verification | Stripe webhook signature verification |
## Deployment Considerations
### Database
- Use a managed PostgreSQL service (e.g., AWS RDS, Supabase, Neon) in production
- Run `npx prisma migrate deploy` to apply migrations
- The Docker setup is intended for local development only
### File Storage
- Replace local Multer storage with S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
- Update the upload middleware and static file serving accordingly
### Environment
- Set strong, unique values for `JWT_SECRET` and `JWT_REFRESH_SECRET`
- Use production Stripe keys and configure webhook endpoints
- Set `CLIENT_URL` to the deployed frontend URL
### Scaling
- **Horizontal scaling:** Socket.io requires a Redis adapter (`@socket.io/redis-adapter`) for multi-instance deployments
- **Static assets:** Serve the built React app via a CDN or reverse proxy (Nginx, Cloudflare)
- **API:** The Express server is stateless (JWT-based auth) and can be scaled horizontally behind a load balancer
### Recommended Production Stack
```
┌───────────┐
│ CDN / │
│ Nginx │
└─────┬─────┘
┌───────────┴───────────┐
│ │
┌────────▼─────┐ ┌─────────▼────────┐
│ React Static │ │ Express API x N │
│ (built) │ │ (load balanced) │
└──────────────┘ └────────┬──────────┘
┌───────────────┼───────────────┐
│ │ │
┌──────▼──┐ ┌──────▼──┐ ┌───────▼──┐
│PostgreSQL│ │ Redis │ │ S3 / R2 │
│(managed) │ │(sockets)│ │ (files) │
└──────────┘ └─────────┘ └──────────┘
```

399
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0",
"recharts": "^3.7.0",
"socket.io-client": "^4.8.0"
},
"devDependencies": {
@@ -1109,6 +1110,42 @@
"@prisma/debug": "6.19.2"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1476,7 +1513,12 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
@@ -1866,6 +1908,69 @@
"@types/node": "*"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1975,7 +2080,7 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -2024,6 +2129,12 @@
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz",
@@ -2711,6 +2822,15 @@
"consola": "^3.2.3"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2868,9 +2988,130 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -2888,6 +3129,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3112,6 +3359,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
@@ -3369,6 +3626,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -3848,6 +4111,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3881,6 +4154,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5041,6 +5323,29 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5137,6 +5442,57 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5563,6 +5919,12 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@@ -5758,6 +6120,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -5782,6 +6153,28 @@
"node": ">= 0.8"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",

View File

@@ -0,0 +1,173 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN');
-- CreateEnum
CREATE TYPE "ReportReason" AS ENUM ('SPAM', 'INAPPROPRIATE', 'SCAM', 'COUNTERFEIT', 'PROHIBITED_ITEM', 'HARASSMENT', 'OTHER');
-- CreateEnum
CREATE TYPE "ReportStatus" AS ENUM ('OPEN', 'REVIEWING', 'RESOLVED', 'DISMISSED');
-- CreateEnum
CREATE TYPE "ReportTargetType" AS ENUM ('LISTING', 'USER');
-- CreateEnum
CREATE TYPE "SubscriptionTier" AS ENUM ('BASIC', 'PRO', 'BUSINESS');
-- CreateEnum
CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'CANCELLED', 'EXPIRED', 'PAST_DUE');
-- CreateEnum
CREATE TYPE "PaymentType" AS ENUM ('LISTING_FEE', 'COMMISSION', 'PROMOTION', 'SUBSCRIPTION');
-- CreateEnum
CREATE TYPE "ModerationAction" AS ENUM ('APPROVED', 'REJECTED', 'WARNING', 'BAN', 'UNBAN', 'LISTING_DELETED', 'LISTING_FEATURED');
-- AlterEnum
ALTER TYPE "ListingStatus" ADD VALUE 'PENDING_REVIEW';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'LISTING_APPROVED';
ALTER TYPE "NotificationType" ADD VALUE 'LISTING_REJECTED';
ALTER TYPE "NotificationType" ADD VALUE 'MODERATION_WARNING';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_BANNED';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_UNBANNED';
ALTER TYPE "NotificationType" ADD VALUE 'REPORT_RESOLVED';
-- AlterTable
ALTER TABLE "Listing" ADD COLUMN "isFeatured" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rejectionReason" TEXT,
ADD COLUMN "reviewedAt" TIMESTAMP(3),
ADD COLUMN "reviewedBy" TEXT;
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "description" TEXT,
ADD COLUMN "type" "PaymentType" NOT NULL DEFAULT 'LISTING_FEE';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "banReason" TEXT,
ADD COLUMN "bannedAt" TIMESTAMP(3),
ADD COLUMN "bannedBy" TEXT,
ADD COLUMN "isBanned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
-- CreateTable
CREATE TABLE "Report" (
"id" TEXT NOT NULL,
"reporterId" TEXT NOT NULL,
"targetType" "ReportTargetType" NOT NULL,
"targetId" TEXT NOT NULL,
"reason" "ReportReason" NOT NULL,
"description" TEXT,
"status" "ReportStatus" NOT NULL DEFAULT 'OPEN',
"resolvedBy" TEXT,
"resolution" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Report_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PlatformConfig" (
"id" TEXT NOT NULL,
"listingFee" DOUBLE PRECISION NOT NULL DEFAULT 5.00,
"commissionPercent" DOUBLE PRECISION NOT NULL DEFAULT 5.0,
"autoApprove" BOOLEAN NOT NULL DEFAULT true,
"maxImagesPerListing" INTEGER NOT NULL DEFAULT 6,
"maxListingsFreeTier" INTEGER NOT NULL DEFAULT 5,
"proPrice" DOUBLE PRECISION NOT NULL DEFAULT 9.99,
"businessPrice" DOUBLE PRECISION NOT NULL DEFAULT 29.99,
"promotionDayPrice" DOUBLE PRECISION NOT NULL DEFAULT 2.99,
"blockedKeywords" TEXT[] DEFAULT ARRAY[]::TEXT[],
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PlatformConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Subscription" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tier" "SubscriptionTier" NOT NULL DEFAULT 'BASIC',
"status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE',
"stripeSubscriptionId" TEXT,
"currentPeriodEnd" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromotedListing" (
"id" TEXT NOT NULL,
"listingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endDate" TIMESTAMP(3) NOT NULL,
"amountPaid" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromotedListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ModerationLog" (
"id" TEXT NOT NULL,
"moderatorId" TEXT NOT NULL,
"targetUserId" TEXT,
"targetListingId" TEXT,
"action" "ModerationAction" NOT NULL,
"reason" TEXT,
"details" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ModerationLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Report_status_idx" ON "Report"("status");
-- CreateIndex
CREATE INDEX "Report_targetType_targetId_idx" ON "Report"("targetType", "targetId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Subscription_stripeSubscriptionId_key" ON "Subscription"("stripeSubscriptionId");
-- CreateIndex
CREATE UNIQUE INDEX "PromotedListing_listingId_key" ON "PromotedListing"("listingId");
-- CreateIndex
CREATE INDEX "PromotedListing_isActive_endDate_idx" ON "PromotedListing"("isActive", "endDate");
-- CreateIndex
CREATE INDEX "ModerationLog_moderatorId_idx" ON "ModerationLog"("moderatorId");
-- CreateIndex
CREATE INDEX "ModerationLog_createdAt_idx" ON "ModerationLog"("createdAt");
-- AddForeignKey
ALTER TABLE "Report" ADD CONSTRAINT "Report_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedListing" ADD CONSTRAINT "PromotedListing_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedListing" ADD CONSTRAINT "PromotedListing_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ModerationLog" ADD CONSTRAINT "ModerationLog_moderatorId_fkey" FOREIGN KEY ("moderatorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,336 @@
-- CreateEnum
CREATE TYPE "RentalCategory" AS ENUM ('APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE');
-- CreateEnum
CREATE TYPE "RentalPeriodType" AS ENUM ('DAILY', 'MONTHLY');
-- CreateEnum
CREATE TYPE "RentalListingStatus" AS ENUM ('DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'PAUSED', 'DELETED');
-- CreateEnum
CREATE TYPE "BookingStatus" AS ENUM ('PENDING', 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED_BY_TENANT', 'CANCELLED_BY_LANDLORD', 'REJECTED', 'EXPIRED');
-- CreateEnum
CREATE TYPE "PayoutStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED');
-- CreateEnum
CREATE TYPE "CancellationPolicy" AS ENUM ('FLEXIBLE', 'MODERATE', 'STRICT');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_REQUEST';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_CONFIRMED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_REJECTED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_CANCELLED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_STARTED';
ALTER TYPE "NotificationType" ADD VALUE 'BOOKING_COMPLETED';
ALTER TYPE "NotificationType" ADD VALUE 'RENTAL_REVIEW';
ALTER TYPE "NotificationType" ADD VALUE 'PAYOUT_SENT';
ALTER TYPE "NotificationType" ADD VALUE 'PAYOUT_FAILED';
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_BOOKING';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_COMMISSION';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_DEPOSIT';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_DEPOSIT_REFUND';
ALTER TYPE "PaymentType" ADD VALUE 'RENTAL_PAYOUT';
-- AlterTable
ALTER TABLE "Conversation" ADD COLUMN "rentalListingId" TEXT;
-- AlterTable
ALTER TABLE "PlatformConfig" ADD COLUMN "bookingExpiryHours" INTEGER NOT NULL DEFAULT 48,
ADD COLUMN "maxRentalImagesPerListing" INTEGER NOT NULL DEFAULT 10,
ADD COLUMN "rentalAutoApprove" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "rentalCommissionPercent" DOUBLE PRECISION NOT NULL DEFAULT 10.0,
ADD COLUMN "rentalPromotionDayPrice" DOUBLE PRECISION NOT NULL DEFAULT 3.99;
-- AlterTable
ALTER TABLE "User" ADD COLUMN "isLandlord" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "landlordVerified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "stripeAccountId" TEXT;
-- CreateTable
CREATE TABLE "RentalListing" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"category" "RentalCategory" NOT NULL,
"location" TEXT NOT NULL,
"dailyPrice" DOUBLE PRECISION,
"monthlyPrice" DOUBLE PRECISION,
"depositAmount" DOUBLE PRECISION,
"details" JSONB,
"amenities" TEXT[] DEFAULT ARRAY[]::TEXT[],
"rules" TEXT[] DEFAULT ARRAY[]::TEXT[],
"cancellationPolicy" "CancellationPolicy" NOT NULL DEFAULT 'FLEXIBLE',
"minDays" INTEGER,
"maxDays" INTEGER,
"minMonths" INTEGER,
"maxMonths" INTEGER,
"status" "RentalListingStatus" NOT NULL DEFAULT 'DRAFT',
"viewCount" INTEGER NOT NULL DEFAULT 0,
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
"isVerified" BOOLEAN NOT NULL DEFAULT false,
"rejectionReason" TEXT,
"reviewedBy" TEXT,
"reviewedAt" TIMESTAMP(3),
"landlordId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalListing_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalImage" (
"id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"rentalListingId" TEXT NOT NULL,
CONSTRAINT "RentalImage_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AvailabilityBlock" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"isBlocked" BOOLEAN NOT NULL DEFAULT true,
"reason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AvailabilityBlock_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Booking" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"periodType" "RentalPeriodType" NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL,
"endDate" TIMESTAMP(3) NOT NULL,
"pricePerPeriod" DOUBLE PRECISION NOT NULL,
"totalPeriods" INTEGER NOT NULL,
"subtotal" DOUBLE PRECISION NOT NULL,
"commissionRate" DOUBLE PRECISION NOT NULL DEFAULT 10.0,
"commissionAmount" DOUBLE PRECISION NOT NULL,
"depositAmount" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalAmount" DOUBLE PRECISION NOT NULL,
"status" "BookingStatus" NOT NULL DEFAULT 'PENDING',
"message" TEXT,
"rejectionReason" TEXT,
"cancellationReason" TEXT,
"stripePaymentIntentId" TEXT,
"expiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Booking_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BookingPayment" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"stripePaymentId" TEXT,
"amount" DOUBLE PRECISION NOT NULL,
"type" "PaymentType" NOT NULL,
"status" "PaymentStatus" NOT NULL DEFAULT 'PENDING',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BookingPayment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Payout" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"grossAmount" DOUBLE PRECISION NOT NULL,
"commissionAmount" DOUBLE PRECISION NOT NULL,
"netAmount" DOUBLE PRECISION NOT NULL,
"status" "PayoutStatus" NOT NULL DEFAULT 'PENDING',
"stripeTransferId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Payout_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalReview" (
"id" TEXT NOT NULL,
"bookingId" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"landlordId" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"comment" TEXT,
"landlordResponse" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "RentalReview_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RentalFavorite" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RentalFavorite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PromotedRental" (
"id" TEXT NOT NULL,
"rentalListingId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endDate" TIMESTAMP(3) NOT NULL,
"amountPaid" DOUBLE PRECISION NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PromotedRental_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RentalListing_landlordId_idx" ON "RentalListing"("landlordId");
-- CreateIndex
CREATE INDEX "RentalListing_category_idx" ON "RentalListing"("category");
-- CreateIndex
CREATE INDEX "RentalListing_status_idx" ON "RentalListing"("status");
-- CreateIndex
CREATE INDEX "RentalListing_createdAt_idx" ON "RentalListing"("createdAt");
-- CreateIndex
CREATE INDEX "RentalImage_rentalListingId_idx" ON "RentalImage"("rentalListingId");
-- CreateIndex
CREATE INDEX "AvailabilityBlock_rentalListingId_idx" ON "AvailabilityBlock"("rentalListingId");
-- CreateIndex
CREATE INDEX "AvailabilityBlock_startDate_endDate_idx" ON "AvailabilityBlock"("startDate", "endDate");
-- CreateIndex
CREATE INDEX "Booking_rentalListingId_idx" ON "Booking"("rentalListingId");
-- CreateIndex
CREATE INDEX "Booking_tenantId_idx" ON "Booking"("tenantId");
-- CreateIndex
CREATE INDEX "Booking_landlordId_idx" ON "Booking"("landlordId");
-- CreateIndex
CREATE INDEX "Booking_status_idx" ON "Booking"("status");
-- CreateIndex
CREATE UNIQUE INDEX "BookingPayment_stripePaymentId_key" ON "BookingPayment"("stripePaymentId");
-- CreateIndex
CREATE INDEX "BookingPayment_bookingId_idx" ON "BookingPayment"("bookingId");
-- CreateIndex
CREATE UNIQUE INDEX "Payout_bookingId_key" ON "Payout"("bookingId");
-- CreateIndex
CREATE INDEX "Payout_landlordId_idx" ON "Payout"("landlordId");
-- CreateIndex
CREATE INDEX "Payout_status_idx" ON "Payout"("status");
-- CreateIndex
CREATE UNIQUE INDEX "RentalReview_bookingId_key" ON "RentalReview"("bookingId");
-- CreateIndex
CREATE INDEX "RentalReview_rentalListingId_idx" ON "RentalReview"("rentalListingId");
-- CreateIndex
CREATE INDEX "RentalReview_landlordId_idx" ON "RentalReview"("landlordId");
-- CreateIndex
CREATE UNIQUE INDEX "RentalFavorite_userId_rentalListingId_key" ON "RentalFavorite"("userId", "rentalListingId");
-- CreateIndex
CREATE UNIQUE INDEX "PromotedRental_rentalListingId_key" ON "PromotedRental"("rentalListingId");
-- CreateIndex
CREATE INDEX "PromotedRental_isActive_endDate_idx" ON "PromotedRental"("isActive", "endDate");
-- AddForeignKey
ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalListing" ADD CONSTRAINT "RentalListing_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalImage" ADD CONSTRAINT "RentalImage_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AvailabilityBlock" ADD CONSTRAINT "AvailabilityBlock_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Booking" ADD CONSTRAINT "Booking_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BookingPayment" ADD CONSTRAINT "BookingPayment_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Payout" ADD CONSTRAINT "Payout_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalReview" ADD CONSTRAINT "RentalReview_landlordId_fkey" FOREIGN KEY ("landlordId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalFavorite" ADD CONSTRAINT "RentalFavorite_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RentalFavorite" ADD CONSTRAINT "RentalFavorite_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedRental" ADD CONSTRAINT "PromotedRental_rentalListingId_fkey" FOREIGN KEY ("rentalListingId") REFERENCES "RentalListing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PromotedRental" ADD CONSTRAINT "PromotedRental_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -29,6 +29,7 @@ enum ListingCondition {
enum ListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
SOLD
DELETED
@@ -50,6 +51,21 @@ enum NotificationType {
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
}
enum PaymentStatus {
@@ -59,6 +75,120 @@ enum PaymentStatus {
REFUNDED
}
enum UserRole {
USER
MODERATOR
ADMIN
SUPER_ADMIN
}
enum ReportReason {
SPAM
INAPPROPRIATE
SCAM
COUNTERFEIT
PROHIBITED_ITEM
HARASSMENT
OTHER
}
enum ReportStatus {
OPEN
REVIEWING
RESOLVED
DISMISSED
}
enum ReportTargetType {
LISTING
USER
}
enum SubscriptionTier {
BASIC
PRO
BUSINESS
}
enum SubscriptionStatus {
ACTIVE
CANCELLED
EXPIRED
PAST_DUE
}
enum PaymentType {
LISTING_FEE
COMMISSION
PROMOTION
SUBSCRIPTION
RENTAL_BOOKING
RENTAL_COMMISSION
RENTAL_DEPOSIT
RENTAL_DEPOSIT_REFUND
RENTAL_PAYOUT
}
enum ModerationAction {
APPROVED
REJECTED
WARNING
BAN
UNBAN
LISTING_DELETED
LISTING_FEATURED
}
// ── Rental enums ──────────────────────────────────────────────────
enum RentalCategory {
APARTMENT
HOUSE
CAR
MOTORCYCLE
BICYCLE
EBIKE
}
enum RentalPeriodType {
DAILY
MONTHLY
}
enum RentalListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
PAUSED
DELETED
}
enum BookingStatus {
PENDING
CONFIRMED
ACTIVE
COMPLETED
CANCELLED_BY_TENANT
CANCELLED_BY_LANDLORD
REJECTED
EXPIRED
}
enum PayoutStatus {
PENDING
PROCESSING
COMPLETED
FAILED
}
enum CancellationPolicy {
FLEXIBLE
MODERATE
STRICT
}
// ── Models ────────────────────────────────────────────────────────
model User {
id String @id @default(cuid())
email String @unique
@@ -71,6 +201,11 @@ model User {
bio String?
rating Float @default(0)
ratingCount Int @default(0)
role UserRole @default(USER)
isBanned Boolean @default(false)
banReason String?
bannedAt DateTime?
bannedBy String?
showEmail Boolean @default(false)
showPhone Boolean @default(true)
showLocation Boolean @default(true)
@@ -86,6 +221,9 @@ model User {
marketingEmail Boolean @default(false)
resetToken String? @unique
resetTokenExpiry DateTime?
isLandlord Boolean @default(false)
landlordVerified Boolean @default(false)
stripeAccountId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -102,6 +240,20 @@ model User {
payments Payment[]
blockedUsers BlockedUser[] @relation("Blocker")
blockedBy BlockedUser[] @relation("Blocked")
reports Report[]
subscription Subscription?
promotedListings PromotedListing[]
moderationLogs ModerationLog[]
// Rental relations
rentalListings RentalListing[]
tenantBookings Booking[] @relation("TenantBookings")
landlordBookings Booking[] @relation("LandlordBookings")
payouts Payout[]
rentalReviews RentalReview[] @relation("TenantReviews")
landlordReviews RentalReview[] @relation("LandlordReviews")
rentalFavorites RentalFavorite[]
promotedRentals PromotedRental[]
}
model Session {
@@ -129,6 +281,10 @@ model Listing {
status ListingStatus @default(DRAFT)
location String
viewCount Int @default(0)
isFeatured Boolean @default(false)
rejectionReason String?
reviewedBy String?
reviewedAt DateTime?
sellerId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -139,6 +295,7 @@ model Listing {
conversations Conversation[]
favorites Favorite[]
payments Payment[]
promotedListing PromotedListing?
@@index([sellerId])
@@index([category])
@@ -186,12 +343,14 @@ model Conversation {
user1Id String
user2Id String
listingId String?
rentalListingId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user1 User @relation("User1Conversations", fields: [user1Id], references: [id], onDelete: Cascade)
user2 User @relation("User2Conversations", fields: [user2Id], references: [id], onDelete: Cascade)
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
rentalListing RentalListing? @relation(fields: [rentalListingId], references: [id], onDelete: SetNull)
messages Message[]
@@unique([user1Id, user2Id, listingId])
@@ -250,6 +409,8 @@ model Payment {
stripePaymentId String? @unique
amount Float
status PaymentStatus @default(PENDING)
type PaymentType @default(LISTING_FEE)
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -271,3 +432,276 @@ model BlockedUser {
@@unique([blockerId, blockedId])
}
model Report {
id String @id @default(cuid())
reporterId String
targetType ReportTargetType
targetId String
reason ReportReason
description String?
status ReportStatus @default(OPEN)
resolvedBy String?
resolution String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
reporter User @relation(fields: [reporterId], references: [id], onDelete: Cascade)
@@index([status])
@@index([targetType, targetId])
}
model PlatformConfig {
id String @id @default(cuid())
listingFee Float @default(5.00)
commissionPercent Float @default(5.0)
autoApprove Boolean @default(true)
maxImagesPerListing Int @default(6)
maxListingsFreeTier Int @default(5)
proPrice Float @default(9.99)
businessPrice Float @default(29.99)
promotionDayPrice Float @default(2.99)
blockedKeywords String[] @default([])
rentalCommissionPercent Float @default(10.0)
rentalAutoApprove Boolean @default(false)
maxRentalImagesPerListing Int @default(10)
bookingExpiryHours Int @default(48)
rentalPromotionDayPrice Float @default(3.99)
updatedAt DateTime @updatedAt
}
model Subscription {
id String @id @default(cuid())
userId String @unique
tier SubscriptionTier @default(BASIC)
status SubscriptionStatus @default(ACTIVE)
stripeSubscriptionId String? @unique
currentPeriodEnd DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model PromotedListing {
id String @id @default(cuid())
listingId String @unique
userId String
startDate DateTime @default(now())
endDate DateTime
amountPaid Float
isActive Boolean @default(true)
createdAt DateTime @default(now())
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isActive, endDate])
}
model ModerationLog {
id String @id @default(cuid())
moderatorId String
targetUserId String?
targetListingId String?
action ModerationAction
reason String?
details Json?
createdAt DateTime @default(now())
moderator User @relation(fields: [moderatorId], references: [id], onDelete: Cascade)
@@index([moderatorId])
@@index([createdAt])
}
// ── Rental Models ─────────────────────────────────────────────────
model RentalListing {
id String @id @default(cuid())
title String
description String
category RentalCategory
location String
dailyPrice Float?
monthlyPrice Float?
depositAmount Float?
details Json?
amenities String[] @default([])
rules String[] @default([])
cancellationPolicy CancellationPolicy @default(FLEXIBLE)
minDays Int?
maxDays Int?
minMonths Int?
maxMonths Int?
status RentalListingStatus @default(DRAFT)
viewCount Int @default(0)
isFeatured Boolean @default(false)
isVerified Boolean @default(false)
rejectionReason String?
reviewedBy String?
reviewedAt DateTime?
landlordId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
landlord User @relation(fields: [landlordId], references: [id], onDelete: Cascade)
images RentalImage[]
availabilityBlocks AvailabilityBlock[]
bookings Booking[]
reviews RentalReview[]
favorites RentalFavorite[]
promotedRental PromotedRental?
conversations Conversation[]
@@index([landlordId])
@@index([category])
@@index([status])
@@index([createdAt])
}
model RentalImage {
id String @id @default(cuid())
url String
order Int @default(0)
rentalListingId String
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
}
model AvailabilityBlock {
id String @id @default(cuid())
rentalListingId String
startDate DateTime
endDate DateTime
isBlocked Boolean @default(true)
reason String?
createdAt DateTime @default(now())
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
@@index([startDate, endDate])
}
model Booking {
id String @id @default(cuid())
rentalListingId String
tenantId String
landlordId String
periodType RentalPeriodType
startDate DateTime
endDate DateTime
pricePerPeriod Float
totalPeriods Int
subtotal Float
commissionRate Float @default(10.0)
commissionAmount Float
depositAmount Float @default(0)
totalAmount Float
status BookingStatus @default(PENDING)
message String?
rejectionReason String?
cancellationReason String?
stripePaymentIntentId String?
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
tenant User @relation("TenantBookings", fields: [tenantId], references: [id], onDelete: Cascade)
landlord User @relation("LandlordBookings", fields: [landlordId], references: [id], onDelete: Cascade)
payments BookingPayment[]
payout Payout?
review RentalReview?
@@index([rentalListingId])
@@index([tenantId])
@@index([landlordId])
@@index([status])
}
model BookingPayment {
id String @id @default(cuid())
bookingId String
stripePaymentId String? @unique
amount Float
type PaymentType
status PaymentStatus @default(PENDING)
createdAt DateTime @default(now())
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
@@index([bookingId])
}
model Payout {
id String @id @default(cuid())
bookingId String @unique
landlordId String
grossAmount Float
commissionAmount Float
netAmount Float
status PayoutStatus @default(PENDING)
stripeTransferId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
landlord User @relation(fields: [landlordId], references: [id], onDelete: Cascade)
@@index([landlordId])
@@index([status])
}
model RentalReview {
id String @id @default(cuid())
bookingId String @unique
rentalListingId String
tenantId String
landlordId String
rating Int
comment String?
landlordResponse String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
tenant User @relation("TenantReviews", fields: [tenantId], references: [id], onDelete: Cascade)
landlord User @relation("LandlordReviews", fields: [landlordId], references: [id], onDelete: Cascade)
@@index([rentalListingId])
@@index([landlordId])
}
model RentalFavorite {
id String @id @default(cuid())
userId String
rentalListingId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
@@unique([userId, rentalListingId])
}
model PromotedRental {
id String @id @default(cuid())
rentalListingId String @unique
userId String
startDate DateTime @default(now())
endDate DateTime
amountPaid Float
isActive Boolean @default(true)
createdAt DateTime @default(now())
rentalListing RentalListing @relation(fields: [rentalListingId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([isActive, endDate])
}

View File

@@ -11,6 +11,20 @@ async function main() {
// ── Clear existing data (reverse dependency order) ──────────────────
await prisma.$transaction([
prisma.promotedRental.deleteMany(),
prisma.rentalFavorite.deleteMany(),
prisma.rentalReview.deleteMany(),
prisma.payout.deleteMany(),
prisma.bookingPayment.deleteMany(),
prisma.booking.deleteMany(),
prisma.availabilityBlock.deleteMany(),
prisma.rentalImage.deleteMany(),
prisma.rentalListing.deleteMany(),
prisma.moderationLog.deleteMany(),
prisma.promotedListing.deleteMany(),
prisma.subscription.deleteMany(),
prisma.report.deleteMany(),
prisma.platformConfig.deleteMany(),
prisma.message.deleteMany(),
prisma.conversation.deleteMany(),
prisma.notification.deleteMany(),
@@ -780,6 +794,310 @@ async function main() {
console.log('Created 1 blocked user entry.');
// ── Platform Config ───────────────────────────────────────────────
await prisma.platformConfig.deleteMany();
await prisma.platformConfig.create({
data: {
listingFee: 5.00,
commissionPercent: 5.0,
autoApprove: true,
maxImagesPerListing: 6,
maxListingsFreeTier: 5,
proPrice: 9.99,
businessPrice: 29.99,
promotionDayPrice: 2.99,
blockedKeywords: ['illegal', 'drugs', 'weapons'],
rentalCommissionPercent: 10.0,
rentalAutoApprove: false,
maxRentalImagesPerListing: 10,
bookingExpiryHours: 48,
rentalPromotionDayPrice: 3.99,
},
});
console.log('Created PlatformConfig.');
// ── Assign roles to test users ────────────────────────────────────
await prisma.user.update({ where: { id: 'user-alice' }, data: { role: 'SUPER_ADMIN' } });
await prisma.user.update({ where: { id: 'user-bob' }, data: { role: 'ADMIN' } });
await prisma.user.update({ where: { id: 'user-carol' }, data: { role: 'MODERATOR' } });
console.log('Assigned roles: alice=SUPER_ADMIN, bob=ADMIN, carol=MODERATOR.');
// ── Rental Listings ─────────────────────────────────────────────────
// Make david a landlord
await prisma.user.update({ where: { id: 'user-david' }, data: { isLandlord: true, landlordVerified: true } });
// Make eva a landlord too
await prisma.user.update({ where: { id: 'user-eva' }, data: { isLandlord: true } });
const rentalListingsData = [
{
id: 'rental-01',
title: 'Modern Downtown Apartment — 2BR',
description: 'Spacious 2-bedroom apartment in the heart of downtown Chicago. Fully furnished with modern appliances, high-speed WiFi, and stunning city views from the 15th floor. Walking distance to restaurants, shops, and public transit.',
category: 'APARTMENT' as const,
location: 'Chicago, IL',
dailyPrice: 120.00,
monthlyPrice: 2800.00,
depositAmount: 500.00,
amenities: ['WiFi', 'Air Conditioning', 'Washer/Dryer', 'Parking', 'Gym', 'Elevator'],
rules: ['No smoking', 'No pets', 'Quiet hours 10PM-8AM'],
cancellationPolicy: 'MODERATE' as const,
minDays: 2,
maxDays: 90,
minMonths: 1,
maxMonths: 12,
status: 'ACTIVE' as const,
viewCount: 234,
isVerified: true,
landlordId: 'user-david',
},
{
id: 'rental-02',
title: 'Cozy Lake House with Private Dock',
description: 'Beautiful 3-bedroom lake house with private dock and boat access. Perfect for families or groups. Includes kayaks, paddleboards, and fishing equipment. Surrounded by nature with hiking trails nearby.',
category: 'HOUSE' as const,
location: 'Lake Geneva, WI',
dailyPrice: 250.00,
monthlyPrice: 5000.00,
depositAmount: 1000.00,
amenities: ['WiFi', 'Fireplace', 'BBQ', 'Boat Dock', 'Kayaks', 'Hot Tub'],
rules: ['No parties', 'No smoking indoors', 'Pets allowed with deposit'],
cancellationPolicy: 'STRICT' as const,
minDays: 3,
maxDays: 30,
status: 'ACTIVE' as const,
viewCount: 189,
landlordId: 'user-david',
},
{
id: 'rental-03',
title: 'Tesla Model 3 — Daily/Monthly Rental',
description: 'Clean 2024 Tesla Model 3 Long Range in Pearl White. Autopilot included. Full charge gives 350+ miles range. Insurance included in daily rate. Must be 25+ with valid license.',
category: 'CAR' as const,
location: 'Seattle, WA',
dailyPrice: 85.00,
monthlyPrice: 1800.00,
depositAmount: 500.00,
details: { year: 2024, make: 'Tesla', model: 'Model 3', color: 'Pearl White', mileage: 12000 },
amenities: ['Autopilot', 'Premium Audio', 'Heated Seats', 'Phone Charger'],
rules: ['No smoking', 'Must be 25+', 'Valid license required', 'Return fully charged'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 60,
status: 'ACTIVE' as const,
viewCount: 156,
landlordId: 'user-eva',
},
{
id: 'rental-04',
title: 'Harley-Davidson Sportster 883',
description: 'Classic Harley-Davidson Sportster 883 in Vivid Black. Perfect for weekend rides or road trips. Helmet included. Motorcycle license required.',
category: 'MOTORCYCLE' as const,
location: 'Portland, OR',
dailyPrice: 65.00,
depositAmount: 300.00,
amenities: ['Helmet Included', 'Saddlebags', 'Phone Mount'],
rules: ['Motorcycle license required', 'Must be 21+', 'No off-road use'],
cancellationPolicy: 'MODERATE' as const,
minDays: 1,
maxDays: 14,
status: 'ACTIVE' as const,
viewCount: 98,
landlordId: 'user-eva',
},
{
id: 'rental-05',
title: 'Trek City Bicycle — Daily Rental',
description: 'Comfortable Trek city bicycle perfect for exploring Portland. Includes lock, lights, and basket. Helmet available on request.',
category: 'BICYCLE' as const,
location: 'Portland, OR',
dailyPrice: 15.00,
amenities: ['Lock', 'Lights', 'Basket', 'Helmet Available'],
rules: ['Return by 8PM', 'Lock when unattended'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 7,
status: 'ACTIVE' as const,
viewCount: 67,
landlordId: 'user-eva',
},
{
id: 'rental-06',
title: 'VanMoof S5 Electric Bike',
description: 'Premium VanMoof S5 e-bike with boost button. Range up to 90 miles. Built-in anti-theft. Perfect for daily commuting or weekend exploring.',
category: 'EBIKE' as const,
location: 'San Francisco, CA',
dailyPrice: 35.00,
monthlyPrice: 600.00,
depositAmount: 200.00,
amenities: ['Anti-theft', 'Boost Button', 'Phone Mount', 'Lock'],
rules: ['Must wear helmet', 'Return charged', 'No off-road'],
cancellationPolicy: 'FLEXIBLE' as const,
minDays: 1,
maxDays: 30,
status: 'ACTIVE' as const,
viewCount: 112,
landlordId: 'user-david',
},
{
id: 'rental-07',
title: 'Luxury Penthouse — Pending Review',
description: 'Stunning penthouse apartment with panoramic views. 3 bedrooms, chef kitchen, private terrace. Under review.',
category: 'APARTMENT' as const,
location: 'Los Angeles, CA',
dailyPrice: 450.00,
monthlyPrice: 9000.00,
depositAmount: 2000.00,
amenities: ['Pool', 'Gym', 'Concierge', 'Parking', 'Terrace'],
rules: ['No parties', 'No smoking'],
cancellationPolicy: 'STRICT' as const,
status: 'PENDING_REVIEW' as const,
viewCount: 0,
landlordId: 'user-david',
},
];
const rentalListings = await prisma.$transaction(
rentalListingsData.map((r) => prisma.rentalListing.create({ data: r }))
);
console.log(`Created ${rentalListings.length} rental listings.`);
// ── Rental Images ─────────────────────────────────────────────────
const rentalImageRecords = [
{ rentalListingId: 'rental-01', url: '/uploads/placeholder-r1.jpg', order: 0 },
{ rentalListingId: 'rental-01', url: '/uploads/placeholder-r2.jpg', order: 1 },
{ rentalListingId: 'rental-02', url: '/uploads/placeholder-r3.jpg', order: 0 },
{ rentalListingId: 'rental-02', url: '/uploads/placeholder-r4.jpg', order: 1 },
{ rentalListingId: 'rental-03', url: '/uploads/placeholder-r5.jpg', order: 0 },
{ rentalListingId: 'rental-04', url: '/uploads/placeholder-r6.jpg', order: 0 },
{ rentalListingId: 'rental-05', url: '/uploads/placeholder-r7.jpg', order: 0 },
{ rentalListingId: 'rental-06', url: '/uploads/placeholder-r8.jpg', order: 0 },
];
await prisma.$transaction(
rentalImageRecords.map((img) => prisma.rentalImage.create({ data: img }))
);
console.log(`Created ${rentalImageRecords.length} rental images.`);
// ── Bookings ──────────────────────────────────────────────────────
const bookingsData = [
{
id: 'booking-01',
rentalListingId: 'rental-01',
tenantId: 'user-eva',
landlordId: 'user-david',
periodType: 'DAILY' as const,
startDate: daysAgo(-5),
endDate: daysAgo(-2),
pricePerPeriod: 120,
totalPeriods: 3,
subtotal: 360,
commissionRate: 10,
commissionAmount: 36,
depositAmount: 500,
totalAmount: 860,
status: 'COMPLETED' as const,
createdAt: daysAgo(10),
},
{
id: 'booking-02',
rentalListingId: 'rental-03',
tenantId: 'user-alice',
landlordId: 'user-eva',
periodType: 'DAILY' as const,
startDate: daysAgo(2),
endDate: daysAgo(-5),
pricePerPeriod: 85,
totalPeriods: 7,
subtotal: 595,
commissionRate: 10,
commissionAmount: 59.5,
depositAmount: 500,
totalAmount: 1095,
status: 'ACTIVE' as const,
createdAt: daysAgo(5),
},
{
id: 'booking-03',
rentalListingId: 'rental-02',
tenantId: 'user-bob',
landlordId: 'user-david',
periodType: 'DAILY' as const,
startDate: daysAgo(-10),
endDate: daysAgo(-7),
pricePerPeriod: 250,
totalPeriods: 3,
subtotal: 750,
commissionRate: 10,
commissionAmount: 75,
depositAmount: 1000,
totalAmount: 1750,
status: 'CONFIRMED' as const,
createdAt: daysAgo(3),
},
{
id: 'booking-04',
rentalListingId: 'rental-05',
tenantId: 'user-carol',
landlordId: 'user-eva',
periodType: 'DAILY' as const,
startDate: daysAgo(-14),
endDate: daysAgo(-12),
pricePerPeriod: 15,
totalPeriods: 2,
subtotal: 30,
commissionRate: 10,
commissionAmount: 3,
depositAmount: 0,
totalAmount: 30,
status: 'PENDING' as const,
expiresAt: daysAgo(-12),
createdAt: daysAgo(1),
},
];
const bookings = await prisma.$transaction(
bookingsData.map((b) => prisma.booking.create({ data: b }))
);
console.log(`Created ${bookings.length} bookings.`);
// ── Payouts for completed bookings ────────────────────────────────
await prisma.payout.create({
data: {
bookingId: 'booking-01',
landlordId: 'user-david',
grossAmount: 360,
commissionAmount: 36,
netAmount: 324,
status: 'COMPLETED',
},
});
console.log('Created 1 payout.');
// ── Rental Reviews ────────────────────────────────────────────────
await prisma.rentalReview.create({
data: {
bookingId: 'booking-01',
rentalListingId: 'rental-01',
tenantId: 'user-eva',
landlordId: 'user-david',
rating: 5,
comment: 'Amazing apartment! Super clean, great location, and David was an excellent host. The views were even better than the photos. Would definitely stay again.',
landlordResponse: 'Thank you Eva! You were a wonderful guest. Welcome back anytime!',
},
});
console.log('Created 1 rental review.');
// ── Rental Favorites ──────────────────────────────────────────────
await prisma.$transaction([
prisma.rentalFavorite.create({ data: { userId: 'user-alice', rentalListingId: 'rental-01' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-alice', rentalListingId: 'rental-02' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-bob', rentalListingId: 'rental-03' } }),
prisma.rentalFavorite.create({ data: { userId: 'user-carol', rentalListingId: 'rental-06' } }),
]);
console.log('Created 4 rental favorites.');
console.log('\nSeed completed successfully!');
}

View File

@@ -18,6 +18,15 @@ import notificationRoutes from './routes/notification.js';
import paymentRoutes from './routes/payment.js';
import locationRoutes from './routes/location.js';
import miscRoutes from './routes/misc.js';
import reportRoutes from './routes/report.js';
import subscriptionRoutes from './routes/subscription.js';
import promotionRoutes from './routes/promotion.js';
import adminRoutes from './routes/admin/index.js';
import rentalRoutes from './routes/rental.js';
import bookingRoutes from './routes/booking.js';
import rentalPaymentRoutes from './routes/rental-payment.js';
import payoutRoutes from './routes/payout.js';
import rentalReviewRoutes from './routes/rental-review.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -35,10 +44,11 @@ app.use(cookieParser());
// Stripe webhook needs raw body
app.use('/api/payments/webhook', express.raw({ type: 'application/json' }));
app.use('/api/rental-payments/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
// Rate limiting
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20 });
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 50 });
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
@@ -55,6 +65,15 @@ app.use('/api/notifications', notificationRoutes);
app.use('/api/payments', paymentRoutes);
app.use('/api/location', locationRoutes);
app.use('/api', miscRoutes);
app.use('/api/reports', reportRoutes);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/promotions', promotionRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/rentals', rentalRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/rental-payments', rentalPaymentRoutes);
app.use('/api/payouts', payoutRoutes);
app.use('/api/rental-reviews', rentalReviewRoutes);
// Health check
app.get('/api/health', (_req, res) => {

View File

@@ -5,6 +5,7 @@ declare global {
namespace Express {
interface Request {
userId?: string;
userRole?: string;
}
}
}

View File

@@ -0,0 +1,24 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
export async function checkBanned(req: Request, res: Response, next: NextFunction): Promise<void> {
if (!req.userId) {
next();
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { isBanned: true, banReason: true },
});
if (user?.isBanned) {
res.status(403).json({
message: 'Account suspended',
reason: user.banReason || 'Your account has been suspended. Contact support for more information.',
});
return;
}
next();
}

View File

@@ -0,0 +1,47 @@
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
type Role = 'USER' | 'MODERATOR' | 'ADMIN' | 'SUPER_ADMIN';
const ROLE_HIERARCHY: Record<Role, number> = {
USER: 0,
MODERATOR: 1,
ADMIN: 2,
SUPER_ADMIN: 3,
};
export function requireRole(...roles: Role[]) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
if (!req.userId) {
res.status(401).json({ message: 'Authentication required' });
return;
}
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { role: true, isBanned: true },
});
if (!user) {
res.status(401).json({ message: 'User not found' });
return;
}
if (user.isBanned) {
res.status(403).json({ message: 'Account is suspended' });
return;
}
if (!roles.includes(user.role as Role)) {
res.status(403).json({ message: 'Insufficient permissions' });
return;
}
(req as any).userRole = user.role;
next();
};
}
export const requireModerator = requireRole('MODERATOR', 'ADMIN', 'SUPER_ADMIN');
export const requireAdmin = requireRole('ADMIN', 'SUPER_ADMIN');
export const requireSuperAdmin = requireRole('SUPER_ADMIN');

View File

@@ -0,0 +1,38 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
const router = Router();
// --- List all bookings ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.booking.findMany({
where,
include: {
rentalListing: { select: { id: true, title: true, category: true, location: true, cancellationPolicy: true, images: { take: 1, orderBy: { order: 'asc' as const } } } },
tenant: { select: { id: true, fullName: true, email: true } },
landlord: { select: { id: true, fullName: true, email: true } },
payout: true,
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.booking.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,30 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.js';
import statsRouter from './stats.js';
import usersRouter from './users.js';
import listingsRouter from './listings.js';
import reportsRouter from './reports.js';
import moderationRouter from './moderation.js';
import paymentsRouter from './payments.js';
import settingsRouter from './settings.js';
import rentalRouter from './rentals.js';
import bookingsRouter from './bookings.js';
import rentalPayoutsRouter from './rental-payouts.js';
const router = Router();
// All admin routes require authentication
router.use(authenticate);
router.use('/stats', statsRouter);
router.use('/users', usersRouter);
router.use('/listings', listingsRouter);
router.use('/reports', reportsRouter);
router.use('/moderation', moderationRouter);
router.use('/payments', paymentsRouter);
router.use('/settings', settingsRouter);
router.use('/rentals', rentalRouter);
router.use('/bookings', bookingsRouter);
router.use('/rental-payouts', rentalPayoutsRouter);
export default router;

View File

@@ -0,0 +1,187 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { rejectListingSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/listings - All listings
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
];
}
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where,
select: {
id: true, title: true, price: true, category: true, condition: true,
status: true, isFeatured: true, createdAt: true, viewCount: true,
seller: { select: { id: true, fullName: true, avatar: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { offers: true, favorites: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.listing.count({ where }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/approve
router.post('/:id/approve', requireModerator, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing is not pending review');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'APPROVED',
reason: 'Listing approved',
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_APPROVED',
title: 'Listing Approved',
body: `Your listing "${listing.title}" has been approved and is now live.`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/reject
router.post('/:id/reject', requireModerator, validate(rejectListingSchema), async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: {
status: 'DELETED',
rejectionReason: req.body.reason,
reviewedBy: req.userId,
reviewedAt: new Date(),
},
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'REJECTED',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: listing.sellerId,
type: 'LISTING_REJECTED',
title: 'Listing Rejected',
body: `Your listing "${listing.title}" was rejected. Reason: ${req.body.reason}`,
data: { listingId: listing.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// DELETE /api/admin/listings/:id - Force delete
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'DELETED' },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
targetUserId: listing.sellerId,
action: 'LISTING_DELETED',
reason: 'Force deleted by admin',
},
});
res.json({ message: 'Listing deleted' });
} catch (error) {
next(error);
}
});
// POST /api/admin/listings/:id/feature - Toggle featured
router.post('/:id/feature', requireAdmin, async (req, res, next) => {
try {
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
const updated = await prisma.listing.update({
where: { id: req.params.id },
data: { isFeatured: !listing.isFeatured },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetListingId: req.params.id,
action: 'LISTING_FEATURED',
reason: updated.isFeatured ? 'Listing featured' : 'Listing unfeatured',
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/moderation/queue - Pending review listings
router.get('/queue', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [listings, total] = await Promise.all([
prisma.listing.findMany({
where: { status: 'PENDING_REVIEW' },
select: {
id: true, title: true, description: true, price: true, category: true,
condition: true, location: true, createdAt: true,
seller: { select: { id: true, fullName: true, avatar: true, email: true } },
images: { orderBy: { order: 'asc' } },
},
skip,
take,
orderBy: { createdAt: 'asc' },
}),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
]);
res.json({
data: listings,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/moderation/logs - Moderation history
router.get('/logs', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [logs, total] = await Promise.all([
prisma.moderationLog.findMany({
include: {
moderator: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.moderationLog.count(),
]);
res.json({
data: logs,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,65 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/payments - All payments
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', type, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (type) where.type = type;
if (status) where.status = status;
const [payments, total] = await Promise.all([
prisma.payment.findMany({
where,
include: {
user: { select: { id: true, fullName: true, avatar: true } },
listing: { select: { id: true, title: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.payment.count({ where }),
]);
res.json({
data: payments,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/payments/revenue - Revenue breakdown
router.get('/revenue', requireAdmin, async (_req, res, next) => {
try {
const [listingFees, commissions, promotions, subscriptions] = await Promise.all([
prisma.payment.aggregate({ where: { type: 'LISTING_FEE', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'COMMISSION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'PROMOTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
prisma.payment.aggregate({ where: { type: 'SUBSCRIPTION', status: 'COMPLETED' }, _sum: { amount: true }, _count: true }),
]);
res.json({
listingFees: { total: listingFees._sum.amount || 0, count: listingFees._count },
commissions: { total: commissions._sum.amount || 0, count: commissions._count },
promotions: { total: promotions._sum.amount || 0, count: promotions._count },
subscriptions: { total: subscriptions._sum.amount || 0, count: subscriptions._count },
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../../config/database.js';
import { requireAdmin } from '../../middleware/requireRole.js';
import { env } from '../../config/env.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List all payouts ---
router.get('/', requireAdmin, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
const [data, total] = await Promise.all([
prisma.payout.findMany({
where,
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
},
},
landlord: { select: { id: true, fullName: true, email: true, stripeAccountId: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.payout.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Retry failed payout ---
router.patch('/:id/retry', requireAdmin, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: { landlord: true },
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.status !== 'FAILED' && payout.status !== 'PENDING') {
throw new AppError(400, 'Can only retry failed or pending payouts');
}
if (!payout.landlord.stripeAccountId) {
throw new AppError(400, 'Landlord has no Stripe Connect account');
}
if (stripe) {
try {
const transfer = await stripe.transfers.create({
amount: Math.round(payout.netAmount * 100),
currency: 'usd',
destination: payout.landlord.stripeAccountId,
metadata: { payoutId: payout.id, bookingId: payout.bookingId },
});
await prisma.payout.update({
where: { id: payout.id },
data: { status: 'COMPLETED', stripeTransferId: transfer.id },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_SENT',
title: 'Payout Sent',
body: `Your payout of $${payout.netAmount.toFixed(2)} has been sent.`,
data: { payoutId: payout.id },
},
});
} catch {
await prisma.payout.update({ where: { id: payout.id }, data: { status: 'FAILED' } });
await prisma.notification.create({
data: {
userId: payout.landlordId,
type: 'PAYOUT_FAILED',
title: 'Payout Failed',
body: `Your payout of $${payout.netAmount.toFixed(2)} failed. Our team is investigating.`,
data: { payoutId: payout.id },
},
});
}
}
const updated = await prisma.payout.findUnique({ where: { id: payout.id } });
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,150 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// --- List all rentals ---
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, category, search } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = {};
if (status) where.status = status;
if (category) where.category = category;
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({
where,
select: {
id: true, title: true, category: true, location: true, status: true,
dailyPrice: true, monthlyPrice: true, viewCount: true, isFeatured: true, isVerified: true,
createdAt: true,
landlord: { select: { id: true, fullName: true, email: true } },
images: { take: 1, orderBy: { order: 'asc' } },
_count: { select: { bookings: true, reviews: true } },
},
skip, take,
orderBy: { createdAt: 'desc' },
}),
prisma.rentalListing.count({ where }),
]);
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Approve rental ---
router.patch('/:id/approve', requireModerator, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE', reviewedBy: req.userId, reviewedAt: new Date() },
});
// Notify landlord
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_APPROVED',
title: 'Rental Approved',
body: `Your rental "${rental.title}" has been approved and is now live!`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Reject rental ---
router.patch('/:id/reject', requireModerator, async (req, res, next) => {
try {
const { reason } = req.body;
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'PENDING_REVIEW') throw new AppError(400, 'Rental is not pending review');
const updated = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'DRAFT', rejectionReason: reason, reviewedBy: req.userId, reviewedAt: new Date() },
});
await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'LISTING_REJECTED',
title: 'Rental Rejected',
body: `Your rental "${rental.title}" was rejected. Reason: ${reason || 'Not specified'}`,
data: { rentalListingId: rental.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Force delete rental (admin) ---
router.delete('/:id', requireAdmin, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Rental stats ---
router.get('/stats', requireModerator, async (req, res, next) => {
try {
const [totalRentals, activeRentals, pendingRentals, totalBookings, activeBookings, completedBookings, totalPayouts, pendingPayouts] = await Promise.all([
prisma.rentalListing.count({ where: { status: { not: 'DELETED' } } }),
prisma.rentalListing.count({ where: { status: 'ACTIVE' } }),
prisma.rentalListing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.booking.count(),
prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'ACTIVE'] } } }),
prisma.booking.count({ where: { status: 'COMPLETED' } }),
prisma.payout.count(),
prisma.payout.count({ where: { status: 'PENDING' } }),
]);
const payoutAgg = await prisma.payout.aggregate({
where: { status: 'COMPLETED' },
_sum: { commissionAmount: true, netAmount: true },
});
res.json({
totalRentals, activeRentals, pendingRentals,
totalBookings, activeBookings, completedBookings,
totalPayouts, pendingPayouts,
totalRentalRevenue: payoutAgg._sum.commissionAmount || 0,
totalPaidOut: payoutAgg._sum.netAmount || 0,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,107 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { resolveReportSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/reports
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', status, targetType } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (status) where.status = status;
if (targetType) where.targetType = targetType;
const [reports, total] = await Promise.all([
prisma.report.findMany({
where,
include: {
reporter: { select: { id: true, fullName: true, avatar: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.report.count({ where }),
]);
res.json({
data: reports,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/reports/:id
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const report = await prisma.report.findUnique({
where: { id: req.params.id },
include: {
reporter: { select: { id: true, fullName: true, avatar: true, email: true } },
},
});
if (!report) throw new AppError(404, 'Report not found');
let target: any = null;
if (report.targetType === 'LISTING') {
target = await prisma.listing.findUnique({
where: { id: report.targetId },
select: { id: true, title: true, status: true, seller: { select: { id: true, fullName: true } } },
});
} else {
target = await prisma.user.findUnique({
where: { id: report.targetId },
select: { id: true, fullName: true, email: true, isBanned: true },
});
}
res.json({ report, target });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/reports/:id
router.patch('/:id', requireModerator, validate(resolveReportSchema), async (req, res, next) => {
try {
const report = await prisma.report.findUnique({ where: { id: req.params.id } });
if (!report) throw new AppError(404, 'Report not found');
const updated = await prisma.report.update({
where: { id: req.params.id },
data: {
status: req.body.status,
resolution: req.body.resolution,
resolvedBy: req.userId,
},
});
await prisma.notification.create({
data: {
userId: report.reporterId,
type: 'REPORT_RESOLVED',
title: 'Report Updated',
body: `Your report has been ${req.body.status.toLowerCase()}.`,
data: { reportId: report.id },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,43 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { updateSettingsSchema } from '../../validators/admin.js';
import { invalidateConfigCache } from '../../utils/moderation.js';
const router = Router();
// GET /api/admin/settings
router.get('/', requireAdmin, async (_req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
res.json(config);
} catch (error) {
next(error);
}
});
// PATCH /api/admin/settings
router.patch('/', requireSuperAdmin, validate(updateSettingsSchema), async (req, res, next) => {
try {
let config = await prisma.platformConfig.findFirst();
if (!config) {
config = await prisma.platformConfig.create({ data: {} });
}
const updated = await prisma.platformConfig.update({
where: { id: config.id },
data: req.body,
});
invalidateConfigCache();
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin } from '../../middleware/requireRole.js';
const router = Router();
// GET /api/admin/stats - General stats
router.get('/', requireModerator, async (_req, res, next) => {
try {
const [totalUsers, totalListings, activeListings, pendingListings, totalOffers, totalPayments, activeToday] = await Promise.all([
prisma.user.count(),
prisma.listing.count(),
prisma.listing.count({ where: { status: 'ACTIVE' } }),
prisma.listing.count({ where: { status: 'PENDING_REVIEW' } }),
prisma.offer.count(),
prisma.payment.aggregate({ where: { status: 'COMPLETED' }, _sum: { amount: true } }),
prisma.user.count({ where: { updatedAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) } } }),
]);
res.json({
totalUsers,
totalListings,
activeListings,
pendingListings,
totalOffers,
totalRevenue: totalPayments._sum.amount || 0,
activeToday,
});
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/revenue - Revenue over time
router.get('/revenue', requireAdmin, async (req, res, next) => {
try {
const { period = 'daily' } = req.query;
const days = period === 'monthly' ? 365 : period === 'weekly' ? 90 : 30;
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
const payments = await prisma.payment.findMany({
where: { status: 'COMPLETED', createdAt: { gte: since } },
select: { amount: true, type: true, createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/users - User growth
router.get('/users', requireAdmin, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const users = await prisma.user.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true },
orderBy: { createdAt: 'asc' },
});
res.json(users);
} catch (error) {
next(error);
}
});
// GET /api/admin/stats/listings - Listing activity
router.get('/listings', requireModerator, async (_req, res, next) => {
try {
const since = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const listings = await prisma.listing.findMany({
where: { createdAt: { gte: since } },
select: { createdAt: true, status: true },
orderBy: { createdAt: 'asc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,195 @@
import { Router } from 'express';
import { prisma } from '../../config/database.js';
import { requireModerator, requireAdmin, requireSuperAdmin } from '../../middleware/requireRole.js';
import { validate } from '../../middleware/validate.js';
import { banUserSchema, changeRoleSchema } from '../../validators/admin.js';
import { AppError } from '../../middleware/errorHandler.js';
const router = Router();
// GET /api/admin/users - List users
router.get('/', requireModerator, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', search, role, status } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: any = {};
if (search) {
where.OR = [
{ fullName: { contains: search as string, mode: 'insensitive' } },
{ email: { contains: search as string, mode: 'insensitive' } },
];
}
if (role) where.role = role;
if (status === 'banned') where.isBanned = true;
if (status === 'active') where.isBanned = false;
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
role: true, isBanned: true, banReason: true, createdAt: true, location: true,
_count: { select: { listings: true, sentOffers: true, reports: true } },
},
skip,
take,
orderBy: { createdAt: 'desc' },
}),
prisma.user.count({ where }),
]);
res.json({
data: users,
total,
page: parseInt(page as string),
pageSize: take,
totalPages: Math.ceil(total / take),
});
} catch (error) {
next(error);
}
});
// GET /api/admin/users/:id - User detail
router.get('/:id', requireModerator, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.params.id },
select: {
id: true, email: true, fullName: true, nickname: true, avatar: true,
phone: true, location: true, bio: true, rating: true, ratingCount: true,
role: true, isBanned: true, banReason: true, bannedAt: true, bannedBy: true,
createdAt: true, updatedAt: true,
_count: { select: { listings: true, sentOffers: true, receivedOffers: true, reports: true } },
},
});
if (!user) throw new AppError(404, 'User not found');
const moderationLogs = await prisma.moderationLog.findMany({
where: { OR: [{ targetUserId: req.params.id }, { moderatorId: req.params.id }] },
orderBy: { createdAt: 'desc' },
take: 20,
include: { moderator: { select: { id: true, fullName: true } } },
});
res.json({ user, moderationLogs });
} catch (error) {
next(error);
}
});
// PATCH /api/admin/users/:id/role - Change role
router.patch('/:id/role', requireSuperAdmin, validate(changeRoleSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot change your own role');
}
const user = await prisma.user.update({
where: { id: req.params.id },
data: { role: req.body.role },
select: { id: true, fullName: true, role: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'WARNING',
reason: `Role changed to ${req.body.role}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/ban - Ban user
router.post('/:id/ban', requireAdmin, validate(banUserSchema), async (req, res, next) => {
try {
if (req.params.id === req.userId) {
throw new AppError(400, 'Cannot ban yourself');
}
const target = await prisma.user.findUnique({ where: { id: req.params.id }, select: { role: true } });
if (!target) throw new AppError(404, 'User not found');
if (target.role === 'SUPER_ADMIN') throw new AppError(403, 'Cannot ban a super admin');
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: true,
banReason: req.body.reason,
bannedAt: new Date(),
bannedBy: req.userId,
},
select: { id: true, fullName: true, isBanned: true, banReason: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'BAN',
reason: req.body.reason,
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_BANNED',
title: 'Account Suspended',
body: `Your account has been suspended. Reason: ${req.body.reason}`,
},
});
res.json(user);
} catch (error) {
next(error);
}
});
// POST /api/admin/users/:id/unban - Unban user
router.post('/:id/unban', requireAdmin, async (req, res, next) => {
try {
const user = await prisma.user.update({
where: { id: req.params.id },
data: {
isBanned: false,
banReason: null,
bannedAt: null,
bannedBy: null,
},
select: { id: true, fullName: true, isBanned: true },
});
await prisma.moderationLog.create({
data: {
moderatorId: req.userId!,
targetUserId: req.params.id,
action: 'UNBAN',
reason: 'Account unbanned',
},
});
await prisma.notification.create({
data: {
userId: req.params.id,
type: 'ACCOUNT_UNBANNED',
title: 'Account Restored',
body: 'Your account has been restored. You can now use the platform again.',
},
});
res.json(user);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -20,7 +20,7 @@ router.post('/register', validate(registerSchema), async (req, res, next) => {
const passwordHash = await hashPassword(password);
const user = await prisma.user.create({
data: { fullName, email, passwordHash },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
const accessToken = generateAccessToken(user.id);
@@ -56,6 +56,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const fullUser = await prisma.user.findUnique({ where: { email } });
if (!fullUser) throw new AppError(401, 'Invalid email or password');
if (!fullUser.isActive) throw new AppError(403, 'Account is disabled');
if (fullUser.isBanned) throw new AppError(403, `Account suspended: ${fullUser.banReason || 'Contact support for details'}`);
const valid = await comparePassword(password, fullUser.passwordHash);
if (!valid) throw new AppError(401, 'Invalid email or password');
@@ -82,7 +83,7 @@ router.post('/login', validate(loginSchema), async (req, res, next) => {
const user = await prisma.user.findUnique({
where: { id: fullUser.id },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
res.json({ user, accessToken });
} catch (error) {
@@ -127,7 +128,7 @@ router.get('/me', authenticate, async (req, res, next) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.userId },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true },
select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, showEmail: true, showPhone: true, showLocation: true, role: true, createdAt: true },
});
if (!user) throw new AppError(404, 'User not found');
res.json({ user });

View File

@@ -0,0 +1,357 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createBookingSchema, rejectBookingSchema, cancelBookingSchema } from '../validators/booking.js';
import { AppError } from '../middleware/errorHandler.js';
import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js';
import { getPlatformConfig } from '../utils/moderation.js';
const router = Router();
const bookingInclude = {
rentalListing: {
select: {
id: true, title: true, category: true, location: true, cancellationPolicy: true,
images: { take: 1, orderBy: { order: 'asc' as const } },
},
},
tenant: { select: { id: true, fullName: true, nickname: true, avatar: true } },
landlord: { select: { id: true, fullName: true, nickname: true, avatar: true } },
payout: true,
review: true,
};
// --- List bookings ---
router.get('/', authenticate, async (req, res, next) => {
try {
const { role = 'tenant', status } = req.query;
const where: Record<string, unknown> = role === 'landlord'
? { landlordId: req.userId }
: { tenantId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
}
const bookings = await prisma.booking.findMany({
where,
include: bookingInclude,
orderBy: { createdAt: 'desc' },
});
// Auto-transition stale bookings
const updated = await Promise.all(bookings.map(b => autoTransitionBooking(b)));
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Get single booking ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({
where: { id: req.params.id },
include: bookingInclude,
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) {
throw new AppError(403, 'Not authorized');
}
const transitioned = await autoTransitionBooking(booking);
res.json(transitioned);
} catch (error) {
next(error);
}
});
// --- Create booking request ---
router.post('/', authenticate, validate(createBookingSchema), async (req, res, next) => {
try {
const { rentalListingId, periodType, startDate: startStr, endDate: endStr, message } = req.body;
const rental = await prisma.rentalListing.findUnique({ where: { id: rentalListingId } });
if (!rental) throw new AppError(404, 'Rental not found');
if (rental.status !== 'ACTIVE') throw new AppError(400, 'Rental is not active');
if (rental.landlordId === req.userId) throw new AppError(400, 'Cannot book your own rental');
const startDate = new Date(startStr);
const endDate = new Date(endStr);
if (startDate >= endDate) throw new AppError(400, 'End date must be after start date');
if (startDate < new Date()) throw new AppError(400, 'Start date must be in the future');
// Check price exists for period type
if (periodType === 'DAILY' && !rental.dailyPrice) throw new AppError(400, 'Daily rental not available');
if (periodType === 'MONTHLY' && !rental.monthlyPrice) throw new AppError(400, 'Monthly rental not available');
// Check availability
const available = await checkAvailability(rentalListingId, startDate, endDate);
if (!available) throw new AppError(409, 'Selected dates are not available');
// Calculate pricing
const pricePerPeriod = periodType === 'DAILY' ? rental.dailyPrice! : rental.monthlyPrice!;
let totalPeriods: number;
if (periodType === 'DAILY') {
totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000));
} else {
totalPeriods = Math.ceil((endDate.getTime() - startDate.getTime()) / (30 * 24 * 60 * 60 * 1000));
}
if (totalPeriods < 1) totalPeriods = 1;
// Min/max checks
if (periodType === 'DAILY') {
if (rental.minDays && totalPeriods < rental.minDays) throw new AppError(400, `Minimum ${rental.minDays} days required`);
if (rental.maxDays && totalPeriods > rental.maxDays) throw new AppError(400, `Maximum ${rental.maxDays} days allowed`);
} else {
if (rental.minMonths && totalPeriods < rental.minMonths) throw new AppError(400, `Minimum ${rental.minMonths} months required`);
if (rental.maxMonths && totalPeriods > rental.maxMonths) throw new AppError(400, `Maximum ${rental.maxMonths} months allowed`);
}
const config = await getPlatformConfig();
const subtotal = pricePerPeriod * totalPeriods;
const commissionRate = config.rentalCommissionPercent;
const commissionAmount = subtotal * (commissionRate / 100);
const depositAmount = rental.depositAmount || 0;
const totalAmount = subtotal + depositAmount;
const booking = await prisma.booking.create({
data: {
rentalListingId,
tenantId: req.userId!,
landlordId: rental.landlordId,
periodType,
startDate,
endDate,
pricePerPeriod,
totalPeriods,
subtotal,
commissionRate,
commissionAmount,
depositAmount,
totalAmount,
message,
expiresAt: new Date(Date.now() + config.bookingExpiryHours * 60 * 60 * 1000),
},
include: bookingInclude,
});
// Notify landlord
const tenant = await prisma.user.findUnique({ where: { id: req.userId }, select: { fullName: true } });
const notification = await prisma.notification.create({
data: {
userId: rental.landlordId,
type: 'BOOKING_REQUEST',
title: 'New Booking Request',
body: `${tenant?.fullName || 'Someone'} requested to book "${rental.title}"`,
data: { bookingId: booking.id, rentalListingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${rental.landlordId}`).emit('new_notification', notification);
}
res.status(201).json(booking);
} catch (error) {
next(error);
}
});
// --- Confirm booking (landlord) ---
router.patch('/:id/confirm', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'PENDING') throw new AppError(400, 'Can only confirm pending bookings');
// Check if expired
if (booking.expiresAt && booking.expiresAt < new Date()) {
await prisma.booking.update({ where: { id: booking.id }, data: { status: 'EXPIRED' } });
throw new AppError(400, 'Booking has expired');
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'CONFIRMED' },
include: bookingInclude,
});
// Notify tenant
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_CONFIRMED',
title: 'Booking Confirmed',
body: `Your booking has been confirmed! Please proceed with payment.`,
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Reject booking (landlord) ---
router.patch('/:id/reject', authenticate, validate(rejectBookingSchema), async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'PENDING') throw new AppError(400, 'Can only reject pending bookings');
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'REJECTED', rejectionReason: req.body.reason },
include: bookingInclude,
});
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_REJECTED',
title: 'Booking Rejected',
body: `Your booking was rejected. Reason: ${req.body.reason}`,
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Cancel booking (either side) ---
router.patch('/:id/cancel', authenticate, validate(cancelBookingSchema), async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({
where: { id: req.params.id },
include: { rentalListing: true },
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId && booking.landlordId !== req.userId) {
throw new AppError(403, 'Not authorized');
}
const isTenant = booking.tenantId === req.userId;
const cancelStatus = isTenant ? 'CANCELLED_BY_TENANT' : 'CANCELLED_BY_LANDLORD';
if (!['PENDING', 'CONFIRMED', 'ACTIVE'].includes(booking.status)) {
throw new AppError(400, 'Cannot cancel this booking');
}
// Calculate refund if already paid
let refundAmount = 0;
let depositRefund = booking.depositAmount;
if (booking.status === 'CONFIRMED' || booking.status === 'ACTIVE') {
const refund = calculateCancellationRefund(
booking.rentalListing.cancellationPolicy,
booking.startDate,
booking.subtotal,
booking.depositAmount,
);
refundAmount = refund.refundAmount;
depositRefund = refund.depositRefund;
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: {
status: cancelStatus as any,
cancellationReason: req.body.reason,
},
include: bookingInclude,
});
// Notify other party
const recipientId = isTenant ? booking.landlordId : booking.tenantId;
const notification = await prisma.notification.create({
data: {
userId: recipientId,
type: 'BOOKING_CANCELLED',
title: 'Booking Cancelled',
body: `A booking has been cancelled. Reason: ${req.body.reason}`,
data: { bookingId: booking.id, refundAmount, depositRefund },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${recipientId}`).emit('new_notification', notification);
}
res.json({ ...updated, refundAmount, depositRefund });
} catch (error) {
next(error);
}
});
// --- Complete booking (landlord) ---
router.patch('/:id/complete', authenticate, async (req, res, next) => {
try {
const booking = await prisma.booking.findUnique({ where: { id: req.params.id } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'ACTIVE' && booking.status !== 'CONFIRMED') {
throw new AppError(400, 'Can only complete active/confirmed bookings');
}
const updated = await prisma.booking.update({
where: { id: req.params.id },
data: { status: 'COMPLETED' },
include: bookingInclude,
});
// Create payout
const netAmount = booking.subtotal - booking.commissionAmount;
await prisma.payout.create({
data: {
bookingId: booking.id,
landlordId: booking.landlordId,
grossAmount: booking.subtotal,
commissionAmount: booking.commissionAmount,
netAmount,
status: 'PENDING',
},
});
// Notify tenant
const notification = await prisma.notification.create({
data: {
userId: booking.tenantId,
type: 'BOOKING_COMPLETED',
title: 'Booking Completed',
body: 'Your booking has been completed. Please leave a review!',
data: { bookingId: booking.id },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.tenantId}`).emit('new_notification', notification);
}
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -6,12 +6,14 @@ import { upload } from '../middleware/upload.js';
import { createListingSchema, updateListingSchema } from '../validators/listing.js';
import { AppError } from '../middleware/errorHandler.js';
import { getBlockedUserIds } from '../utils/blocked.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
const router = Router();
const listingSelect = {
id: true, title: true, description: true, price: true, obo: true,
category: true, condition: true, status: true, location: true, viewCount: true,
isFeatured: true,
createdAt: true, updatedAt: true, sellerId: true,
seller: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
@@ -214,8 +216,12 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
// --- Create listing ---
router.post('/', authenticate, validate(createListingSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const listing = await prisma.listing.create({
data: { ...req.body, sellerId: req.userId!, status: 'DRAFT' },
data: { ...req.body, sellerId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: listingSelect,
});
res.status(201).json(listing);
@@ -250,11 +256,16 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
const existing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Listing not found');
if (existing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT') throw new AppError(400, 'Listing is not in draft status');
if (existing.status !== 'DRAFT' && existing.status !== 'PENDING_REVIEW') throw new AppError(400, 'Listing cannot be activated');
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.autoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const listing = await prisma.listing.update({
where: { id: req.params.id },
data: { status: 'ACTIVE' },
data: { status: newStatus },
select: listingSelect,
});
res.json(listing);

View File

@@ -198,6 +198,26 @@ router.patch('/:id', authenticate, validate(respondOfferSchema), async (req, res
where: { listingId: existing.listingId, id: { not: existing.id }, status: 'PENDING' },
data: { status: 'DECLINED' },
});
// Create commission payment
try {
const { getPlatformConfig } = await import('../utils/moderation.js');
const config = await getPlatformConfig();
const saleAmount = existing.status === 'COUNTERED' ? (existing.counterAmount || existing.amount) : existing.amount;
const commission = saleAmount * (config.commissionPercent / 100);
if (commission > 0) {
await prisma.payment.create({
data: {
userId: existing.sellerId,
listingId: existing.listingId,
amount: commission,
status: 'COMPLETED',
type: 'COMMISSION',
description: `${config.commissionPercent}% commission on sale`,
},
});
}
} catch {}
}
const recipientId = existing.buyerId === req.userId ? existing.sellerId : existing.buyerId;

View File

@@ -4,6 +4,7 @@ import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig } from '../utils/moderation.js';
const router = Router();
@@ -63,8 +64,11 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
});
if (existingPayment) throw new AppError(400, 'Listing already paid for');
const config = await getPlatformConfig();
const feeInCents = Math.round(config.listingFee * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: 500,
amount: feeInCents,
currency: 'usd',
metadata: { listingId, userId: req.userId! },
});
@@ -74,8 +78,9 @@ router.post('/create-intent', authenticate, async (req, res, next) => {
userId: req.userId!,
listingId,
stripePaymentId: paymentIntent.id,
amount: 5,
amount: config.listingFee,
status: 'PENDING',
type: 'LISTING_FEE',
},
});

116
server/src/routes/payout.ts Normal file
View File

@@ -0,0 +1,116 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- List payouts ---
router.get('/', authenticate, async (req, res, next) => {
try {
const payouts = await prisma.payout.findMany({
where: { landlordId: req.userId },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true,
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payouts);
} catch (error) {
next(error);
}
});
// --- Get payout details ---
router.get('/:id', authenticate, async (req, res, next) => {
try {
const payout = await prisma.payout.findUnique({
where: { id: req.params.id },
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true } },
tenant: { select: { id: true, fullName: true } },
startDate: true, endDate: true, totalAmount: true, subtotal: true,
},
},
},
});
if (!payout) throw new AppError(404, 'Payout not found');
if (payout.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
res.json(payout);
} catch (error) {
next(error);
}
});
// --- Setup Stripe Connect account ---
router.post('/setup-account', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user) throw new AppError(404, 'User not found');
let accountId = user.stripeAccountId;
if (!accountId) {
const account = await stripe.accounts.create({
type: 'express',
email: user.email,
metadata: { userId: user.id },
});
accountId = account.id;
await prisma.user.update({ where: { id: req.userId }, data: { stripeAccountId: accountId } });
}
const accountLink = await stripe.accountLinks.create({
account: accountId,
refresh_url: `${env.CLIENT_URL}/landlord/payouts`,
return_url: `${env.CLIENT_URL}/landlord/payouts`,
type: 'account_onboarding',
});
res.json({ url: accountLink.url });
} catch (error) {
next(error);
}
});
// --- Check Stripe Connect account status ---
router.get('/account-status', authenticate, async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const user = await prisma.user.findUnique({ where: { id: req.userId } });
if (!user?.stripeAccountId) {
return res.json({ connected: false, detailsSubmitted: false, chargesEnabled: false, payoutsEnabled: false });
}
const account = await stripe.accounts.retrieve(user.stripeAccountId);
res.json({
connected: true,
detailsSubmitted: account.details_submitted,
chargesEnabled: account.charges_enabled,
payoutsEnabled: account.payouts_enabled,
});
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,72 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { getPlatformConfig } from '../utils/moderation.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// POST /api/listings/:id/promote
router.post('/:id/promote', authenticate, async (req, res, next) => {
try {
const { days } = req.body;
if (!days || days < 1 || days > 30) {
throw new AppError(400, 'Days must be between 1 and 30');
}
const listing = await prisma.listing.findUnique({ where: { id: req.params.id } });
if (!listing) throw new AppError(404, 'Listing not found');
if (listing.sellerId !== req.userId) throw new AppError(403, 'Not authorized');
if (listing.status !== 'ACTIVE') throw new AppError(400, 'Listing must be active');
const config = await getPlatformConfig();
const amountPaid = config.promotionDayPrice * days;
const promotion = await prisma.promotedListing.upsert({
where: { listingId: req.params.id },
update: {
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid: { increment: amountPaid },
isActive: true,
},
create: {
listingId: req.params.id,
userId: req.userId!,
endDate: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
amountPaid,
isActive: true,
},
});
// Record payment
await prisma.payment.create({
data: {
userId: req.userId!,
listingId: req.params.id,
amount: amountPaid,
status: 'COMPLETED',
type: 'PROMOTION',
description: `${days}-day listing promotion`,
},
});
res.json(promotion);
} catch (error) {
next(error);
}
});
// GET /api/listings/:id/promotion
router.get('/:id/promotion', authenticate, async (req, res, next) => {
try {
const promotion = await prisma.promotedListing.findUnique({
where: { listingId: req.params.id },
});
res.json(promotion || { isActive: false });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,127 @@
import { Router } from 'express';
import Stripe from 'stripe';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { env } from '../config/env.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null;
// --- Create payment intent for confirmed booking ---
router.post('/create-intent', authenticate, async (req, res, next) => {
try {
const { bookingId } = req.body;
if (!stripe) throw new AppError(500, 'Stripe not configured');
if (!bookingId) throw new AppError(400, 'Booking ID required');
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'CONFIRMED') throw new AppError(400, 'Booking must be confirmed before payment');
// Check if already paid
const existingPayment = await prisma.bookingPayment.findFirst({
where: { bookingId, status: 'COMPLETED', type: 'RENTAL_BOOKING' },
});
if (existingPayment) throw new AppError(400, 'Booking already paid');
const amountInCents = Math.round(booking.totalAmount * 100);
const paymentIntent = await stripe.paymentIntents.create({
amount: amountInCents,
currency: 'usd',
metadata: { bookingId, tenantId: req.userId!, landlordId: booking.landlordId },
});
await prisma.bookingPayment.create({
data: {
bookingId,
stripePaymentId: paymentIntent.id,
amount: booking.totalAmount,
type: 'RENTAL_BOOKING',
status: 'PENDING',
},
});
await prisma.booking.update({
where: { id: bookingId },
data: { stripePaymentIntentId: paymentIntent.id },
});
res.json({ clientSecret: paymentIntent.client_secret });
} catch (error) {
next(error);
}
});
// --- Stripe webhook ---
router.post('/webhook', async (req, res, next) => {
try {
if (!stripe) throw new AppError(500, 'Stripe not configured');
const sig = req.headers['stripe-signature'] as string;
const event = stripe.webhooks.constructEvent(req.body, sig, env.STRIPE_WEBHOOK_SECRET);
if (event.type === 'payment_intent.succeeded') {
const paymentIntent = event.data.object;
const { bookingId } = paymentIntent.metadata;
if (bookingId) {
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'COMPLETED' },
});
// Move booking to ACTIVE if start date has passed, otherwise keep CONFIRMED
const booking = await prisma.booking.findUnique({ where: { id: bookingId } });
if (booking && booking.status === 'CONFIRMED') {
const newStatus = booking.startDate <= new Date() ? 'ACTIVE' : 'CONFIRMED';
await prisma.booking.update({ where: { id: bookingId }, data: { status: newStatus } });
}
}
} else if (event.type === 'payment_intent.payment_failed') {
const paymentIntent = event.data.object;
await prisma.bookingPayment.updateMany({
where: { stripePaymentId: paymentIntent.id },
data: { status: 'FAILED' },
});
}
res.json({ received: true });
} catch (error) {
next(error);
}
});
// --- Payment history ---
router.get('/history', authenticate, async (req, res, next) => {
try {
const payments = await prisma.bookingPayment.findMany({
where: {
booking: {
OR: [
{ tenantId: req.userId },
{ landlordId: req.userId },
],
},
},
include: {
booking: {
select: {
id: true,
rentalListing: { select: { id: true, title: true, images: { take: 1, orderBy: { order: 'asc' } } } },
},
},
},
orderBy: { createdAt: 'desc' },
});
res.json(payments);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,103 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReviewSchema, respondReviewSchema } from '../validators/rental-review.js';
import { AppError } from '../middleware/errorHandler.js';
const router = Router();
// --- Create review (tenant only, on completed booking) ---
router.post('/', authenticate, validate(createReviewSchema), async (req, res, next) => {
try {
const { bookingId, rating, comment } = req.body;
const booking = await prisma.booking.findUnique({
where: { id: bookingId },
include: { review: true },
});
if (!booking) throw new AppError(404, 'Booking not found');
if (booking.tenantId !== req.userId) throw new AppError(403, 'Not authorized');
if (booking.status !== 'COMPLETED') throw new AppError(400, 'Can only review completed bookings');
if (booking.review) throw new AppError(409, 'Review already exists for this booking');
const review = await prisma.rentalReview.create({
data: {
bookingId,
rentalListingId: booking.rentalListingId,
tenantId: req.userId!,
landlordId: booking.landlordId,
rating,
comment,
},
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
// Notify landlord
const notification = await prisma.notification.create({
data: {
userId: booking.landlordId,
type: 'RENTAL_REVIEW',
title: 'New Review',
body: `You received a ${rating}-star review`,
data: { reviewId: review.id, bookingId },
},
});
const io = req.app.get('io');
if (io) {
io.to(`user:${booking.landlordId}`).emit('new_notification', notification);
}
res.status(201).json(review);
} catch (error) {
next(error);
}
});
// --- Landlord respond to review ---
router.patch('/:id/respond', authenticate, validate(respondReviewSchema), async (req, res, next) => {
try {
const review = await prisma.rentalReview.findUnique({ where: { id: req.params.id } });
if (!review) throw new AppError(404, 'Review not found');
if (review.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const updated = await prisma.rentalReview.update({
where: { id: req.params.id },
data: { landlordResponse: req.body.response },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
},
});
res.json(updated);
} catch (error) {
next(error);
}
});
// --- Get all reviews for a landlord ---
router.get('/landlord/:id', async (req, res, next) => {
try {
const reviews = await prisma.rentalReview.findMany({
where: { landlordId: req.params.id },
include: {
tenant: { select: { id: true, fullName: true, avatar: true } },
rentalListing: { select: { id: true, title: true } },
},
orderBy: { createdAt: 'desc' },
});
const avgRating = reviews.length > 0
? reviews.reduce((sum, r) => sum + r.rating, 0) / reviews.length
: 0;
res.json({ reviews, avgRating, totalReviews: reviews.length });
} catch (error) {
next(error);
}
});
export default router;

406
server/src/routes/rental.ts Normal file
View File

@@ -0,0 +1,406 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate, optionalAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { upload } from '../middleware/upload.js';
import { createRentalSchema, updateRentalSchema } from '../validators/rental.js';
import { AppError } from '../middleware/errorHandler.js';
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
import { checkAvailability } from '../utils/rental.js';
const router = Router();
const rentalSelect = {
id: true, title: true, description: true, category: true, location: true,
dailyPrice: true, monthlyPrice: true, depositAmount: true, details: true,
amenities: true, rules: true, cancellationPolicy: true,
minDays: true, maxDays: true, minMonths: true, maxMonths: true,
status: true, viewCount: true, isFeatured: true, isVerified: true,
rejectionReason: true,
createdAt: true, updatedAt: true, landlordId: true,
landlord: { select: { id: true, fullName: true, nickname: true, avatar: true, rating: true, location: true, createdAt: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true } },
images: { orderBy: { order: 'asc' as const } },
_count: { select: { favorites: true, bookings: true, reviews: true } },
};
// --- My rental listings ---
router.get('/mine', authenticate, async (req, res, next) => {
try {
const { status } = req.query;
const where: Record<string, unknown> = { landlordId: req.userId };
if (status && typeof status === 'string') {
where.status = status;
} else {
where.status = { not: 'DELETED' };
}
const listings = await prisma.rentalListing.findMany({
where,
select: rentalSelect,
orderBy: { createdAt: 'desc' },
});
res.json(listings);
} catch (error) {
next(error);
}
});
// --- Favorites list ---
router.get('/favorites', authenticate, async (req, res, next) => {
try {
const { page = '1', pageSize = '20' } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const [favorites, total] = await Promise.all([
prisma.rentalFavorite.findMany({
where: { userId: req.userId! },
include: { rentalListing: { select: rentalSelect } },
orderBy: { createdAt: 'desc' },
skip, take,
}),
prisma.rentalFavorite.count({ where: { userId: req.userId! } }),
]);
const data = favorites
.filter(f => f.rentalListing.status === 'ACTIVE')
.map(f => ({ ...f.rentalListing, isFavorited: true }));
res.json({ data, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Search/list active rentals ---
router.get('/', optionalAuth, async (req, res, next) => {
try {
const { page = '1', pageSize = '20', category, search, sort = 'newest', priceMin, priceMax, periodType, location } = req.query;
const skip = (parseInt(page as string) - 1) * parseInt(pageSize as string);
const take = parseInt(pageSize as string);
const where: Record<string, unknown> = { status: 'ACTIVE' };
if (category) where.category = category;
if (location && typeof location === 'string') {
where.location = { contains: location, mode: 'insensitive' };
}
if (search) {
where.OR = [
{ title: { contains: search as string, mode: 'insensitive' } },
{ description: { contains: search as string, mode: 'insensitive' } },
{ location: { contains: search as string, mode: 'insensitive' } },
];
}
// Price filters
if (periodType === 'DAILY' || priceMin || priceMax) {
const priceField = periodType === 'MONTHLY' ? 'monthlyPrice' : 'dailyPrice';
const priceFilter: Record<string, unknown> = {};
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
if (Object.keys(priceFilter).length > 0) {
where[priceField] = priceFilter;
}
}
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
: sort === 'price_desc' ? { dailyPrice: 'desc' as const }
: sort === 'popular' ? { viewCount: 'desc' as const }
: { createdAt: 'desc' as const };
const [data, total] = await Promise.all([
prisma.rentalListing.findMany({ where, select: rentalSelect, skip, take, orderBy }),
prisma.rentalListing.count({ where }),
]);
let favIds: Set<string> = new Set();
if (req.userId) {
const favs = await prisma.rentalFavorite.findMany({
where: { userId: req.userId, rentalListingId: { in: data.map(l => l.id) } },
select: { rentalListingId: true },
});
favIds = new Set(favs.map(f => f.rentalListingId));
}
const listings = data.map(l => ({ ...l, isFavorited: favIds.has(l.id) }));
res.json({ data: listings, total, page: parseInt(page as string), pageSize: take, totalPages: Math.ceil(total / take) });
} catch (error) {
next(error);
}
});
// --- Get single rental ---
router.get('/:id', optionalAuth, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({
where: { id: req.params.id },
select: {
...rentalSelect,
landlord: { select: { id: true, email: true, fullName: true, nickname: true, avatar: true, phone: true, location: true, bio: true, rating: true, landlordVerified: true, showEmail: true, showPhone: true, showLocation: true, createdAt: true } },
reviews: {
select: {
id: true, rating: true, comment: true, landlordResponse: true, createdAt: true,
tenant: { select: { id: true, fullName: true, avatar: true } },
},
orderBy: { createdAt: 'desc' as const },
take: 20,
},
},
});
if (!rental || rental.status === 'DELETED') throw new AppError(404, 'Rental not found');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { viewCount: { increment: 1 } } });
let isFavorited = false;
if (req.userId) {
const fav = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId, rentalListingId: rental.id } },
});
isFavorited = !!fav;
}
// Average rating
const avgRating = rental.reviews.length > 0
? rental.reviews.reduce((sum, r) => sum + r.rating, 0) / rental.reviews.length
: 0;
// Privacy
const landlord: Record<string, unknown> = { ...rental.landlord };
if (!rental.landlord.showEmail) delete landlord.email;
if (!rental.landlord.showPhone) delete landlord.phone;
if (!rental.landlord.showLocation) delete landlord.location;
res.json({ ...rental, landlord, isFavorited, avgRating });
} catch (error) {
next(error);
}
});
// --- Create rental ---
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
try {
const config = await getPlatformConfig();
const textToCheck = `${req.body.title} ${req.body.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
// Set user as landlord
await prisma.user.update({ where: { id: req.userId }, data: { isLandlord: true } });
const rental = await prisma.rentalListing.create({
data: { ...req.body, landlordId: req.userId!, status: blockedWord ? 'PENDING_REVIEW' : 'DRAFT' },
select: rentalSelect,
});
res.status(201).json(rental);
} catch (error) {
next(error);
}
});
// --- Update rental ---
router.put('/:id', authenticate, validate(updateRentalSchema), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status === 'DELETED') throw new AppError(400, 'Cannot edit a deleted rental');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: req.body,
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Delete rental (soft) ---
router.delete('/:id', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
await prisma.rentalListing.update({ where: { id: req.params.id }, data: { status: 'DELETED' } });
res.json({ message: 'Rental deleted' });
} catch (error) {
next(error);
}
});
// --- Activate / submit for review ---
router.post('/:id/activate', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'DRAFT' && existing.status !== 'PAUSED' && existing.status !== 'PENDING_REVIEW') {
throw new AppError(400, 'Rental cannot be activated from current status');
}
const config = await getPlatformConfig();
const textToCheck = `${existing.title} ${existing.description}`;
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
const newStatus = (!config.rentalAutoApprove || blockedWord) ? 'PENDING_REVIEW' : 'ACTIVE';
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: newStatus },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Pause rental ---
router.post('/:id/pause', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
if (existing.status !== 'ACTIVE') throw new AppError(400, 'Can only pause active rentals');
const rental = await prisma.rentalListing.update({
where: { id: req.params.id },
data: { status: 'PAUSED' },
select: rentalSelect,
});
res.json(rental);
} catch (error) {
next(error);
}
});
// --- Upload images ---
router.post('/:id/images', authenticate, upload.array('images', 10), async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const files = req.files as Express.Multer.File[];
if (!files?.length) throw new AppError(400, 'No files uploaded');
const existingImages = await prisma.rentalImage.count({ where: { rentalListingId: req.params.id } });
const images = await Promise.all(
files.map((file, i) =>
prisma.rentalImage.create({
data: {
url: `/uploads/${file.filename}`,
order: existingImages + i,
rentalListingId: req.params.id!,
},
})
)
);
res.status(201).json(images);
} catch (error) {
next(error);
}
});
// --- Toggle favorite ---
router.post('/:id/favorite', authenticate, async (req, res, next) => {
try {
const rental = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!rental) throw new AppError(404, 'Rental not found');
const existing = await prisma.rentalFavorite.findUnique({
where: { userId_rentalListingId: { userId: req.userId!, rentalListingId: req.params.id! } },
});
if (existing) {
await prisma.rentalFavorite.delete({ where: { id: existing.id } });
res.json({ isFavorited: false });
} else {
await prisma.rentalFavorite.create({ data: { userId: req.userId!, rentalListingId: req.params.id! } });
res.json({ isFavorited: true });
}
} catch (error) {
next(error);
}
});
// --- Availability ---
router.get('/:id/availability', async (req, res, next) => {
try {
const { start, end } = req.query;
const startDate = start ? new Date(start as string) : new Date();
const endDate = end ? new Date(end as string) : new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
const [blocks, bookings] = await Promise.all([
prisma.availabilityBlock.findMany({
where: {
rentalListingId: req.params.id,
OR: [
{ startDate: { lte: endDate }, endDate: { gte: startDate } },
],
},
orderBy: { startDate: 'asc' },
}),
prisma.booking.findMany({
where: {
rentalListingId: req.params.id,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lte: endDate },
endDate: { gte: startDate },
},
select: { id: true, startDate: true, endDate: true, status: true },
orderBy: { startDate: 'asc' },
}),
]);
res.json({ blocks, bookings });
} catch (error) {
next(error);
}
});
router.post('/:id/availability', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const { startDate, endDate, reason } = req.body;
if (!startDate || !endDate) throw new AppError(400, 'Start and end dates required');
const block = await prisma.availabilityBlock.create({
data: {
rentalListingId: req.params.id!,
startDate: new Date(startDate),
endDate: new Date(endDate),
isBlocked: true,
reason,
},
});
res.status(201).json(block);
} catch (error) {
next(error);
}
});
router.delete('/:id/availability/:blockId', authenticate, async (req, res, next) => {
try {
const existing = await prisma.rentalListing.findUnique({ where: { id: req.params.id } });
if (!existing) throw new AppError(404, 'Rental not found');
if (existing.landlordId !== req.userId) throw new AppError(403, 'Not authorized');
const block = await prisma.availabilityBlock.findUnique({ where: { id: req.params.blockId } });
if (!block || block.rentalListingId !== req.params.id) throw new AppError(404, 'Block not found');
await prisma.availabilityBlock.delete({ where: { id: req.params.blockId } });
res.json({ message: 'Block deleted' });
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createReportSchema } from '../validators/report.js';
const router = Router();
// POST /api/reports - Create report
router.post('/', authenticate, validate(createReportSchema), async (req, res, next) => {
try {
const { targetType, targetId, reason, description } = req.body;
// Verify target exists
if (targetType === 'LISTING') {
const listing = await prisma.listing.findUnique({ where: { id: targetId } });
if (!listing) {
res.status(404).json({ message: 'Listing not found' });
return;
}
} else {
const user = await prisma.user.findUnique({ where: { id: targetId } });
if (!user) {
res.status(404).json({ message: 'User not found' });
return;
}
}
const report = await prisma.report.create({
data: {
reporterId: req.userId!,
targetType,
targetId,
reason,
description,
},
});
res.status(201).json(report);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,76 @@
import { Router } from 'express';
import { prisma } from '../config/database.js';
import { authenticate } from '../middleware/auth.js';
const router = Router();
// GET /api/subscriptions/current
router.get('/current', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({
where: { userId: req.userId! },
});
res.json(subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId });
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/create
router.post('/create', authenticate, async (req, res, next) => {
try {
const { tier } = req.body;
if (!tier || !['PRO', 'BUSINESS'].includes(tier)) {
res.status(400).json({ message: 'Invalid tier' });
return;
}
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (existing && existing.status === 'ACTIVE' && existing.tier !== 'BASIC') {
res.status(400).json({ message: 'Already have an active subscription' });
return;
}
const subscription = await prisma.subscription.upsert({
where: { userId: req.userId! },
update: {
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
create: {
userId: req.userId!,
tier,
status: 'ACTIVE',
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
},
});
res.json(subscription);
} catch (error) {
next(error);
}
});
// POST /api/subscriptions/cancel
router.post('/cancel', authenticate, async (req, res, next) => {
try {
const subscription = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
if (!subscription) {
res.status(404).json({ message: 'No subscription found' });
return;
}
const updated = await prisma.subscription.update({
where: { userId: req.userId! },
data: { status: 'CANCELLED' },
});
res.json(updated);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import { prisma } from '../config/database.js';
interface PlatformConfigCache {
data: {
blockedKeywords: string[];
autoApprove: boolean;
listingFee: number;
commissionPercent: number;
maxImagesPerListing: number;
maxListingsFreeTier: number;
promotionDayPrice: number;
rentalCommissionPercent: number;
rentalAutoApprove: boolean;
maxRentalImagesPerListing: number;
bookingExpiryHours: number;
rentalPromotionDayPrice: number;
} | null;
timestamp: number;
}
const cache: PlatformConfigCache = { data: null, timestamp: 0 };
const CACHE_TTL = 60 * 1000; // 60 seconds
export async function getPlatformConfig() {
const now = Date.now();
if (cache.data && now - cache.timestamp < CACHE_TTL) {
return cache.data;
}
const config = await prisma.platformConfig.findFirst();
if (!config) {
const defaults = {
blockedKeywords: [] as string[],
autoApprove: true,
listingFee: 5.0,
commissionPercent: 5.0,
maxImagesPerListing: 6,
maxListingsFreeTier: 5,
promotionDayPrice: 2.99,
rentalCommissionPercent: 10.0,
rentalAutoApprove: false,
maxRentalImagesPerListing: 10,
bookingExpiryHours: 48,
rentalPromotionDayPrice: 3.99,
};
cache.data = defaults;
cache.timestamp = now;
return defaults;
}
cache.data = {
blockedKeywords: config.blockedKeywords,
autoApprove: config.autoApprove,
listingFee: config.listingFee,
commissionPercent: config.commissionPercent,
maxImagesPerListing: config.maxImagesPerListing,
maxListingsFreeTier: config.maxListingsFreeTier,
promotionDayPrice: config.promotionDayPrice,
rentalCommissionPercent: config.rentalCommissionPercent,
rentalAutoApprove: config.rentalAutoApprove,
maxRentalImagesPerListing: config.maxRentalImagesPerListing,
bookingExpiryHours: config.bookingExpiryHours,
rentalPromotionDayPrice: config.rentalPromotionDayPrice,
};
cache.timestamp = now;
return cache.data;
}
export function invalidateConfigCache() {
cache.data = null;
cache.timestamp = 0;
}
export function checkBlockedKeywords(text: string, blockedKeywords: string[]): string | null {
const lowerText = text.toLowerCase();
for (const keyword of blockedKeywords) {
if (keyword && lowerText.includes(keyword.toLowerCase())) {
return keyword;
}
}
return null;
}

134
server/src/utils/rental.ts Normal file
View File

@@ -0,0 +1,134 @@
import { prisma } from '../config/database.js';
import type { Booking, CancellationPolicy, BookingStatus } from '@prisma/client';
/**
* Checks whether a rental listing is available for the given date range.
* Returns true if no blocked AvailabilityBlock and no CONFIRMED/ACTIVE booking overlaps.
*/
export async function checkAvailability(
rentalListingId: string,
startDate: Date,
endDate: Date
): Promise<boolean> {
const [blockedCount, bookingCount] = await Promise.all([
prisma.availabilityBlock.count({
where: {
rentalListingId,
isBlocked: true,
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
prisma.booking.count({
where: {
rentalListingId,
status: { in: ['CONFIRMED', 'ACTIVE'] },
startDate: { lt: endDate },
endDate: { gt: startDate },
},
}),
]);
return blockedCount === 0 && bookingCount === 0;
}
/**
* Calculates cancellation refund amounts based on the listing's cancellation policy.
*
* - FLEXIBLE: >7 days = 100%, 1-7 days = 100%, <24h = 50%. Deposit always returned.
* - MODERATE: >7 days = 100%, 1-7 days = 50%, <24h = 0%. Deposit always returned.
* - STRICT: >7 days = 50%, 1-7 days = 0%, <24h = 0%. Deposit always returned.
*/
export function calculateCancellationRefund(
policy: CancellationPolicy,
startDate: Date,
subtotal: number,
depositAmount: number
): { refundAmount: number; depositRefund: number } {
const now = new Date();
const msUntilStart = startDate.getTime() - now.getTime();
const hoursUntilStart = msUntilStart / (1000 * 60 * 60);
const daysUntilStart = hoursUntilStart / 24;
let refundPercent: number;
switch (policy) {
case 'FLEXIBLE':
if (hoursUntilStart < 24) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'MODERATE':
if (hoursUntilStart < 24) {
refundPercent = 0;
} else if (daysUntilStart <= 7) {
refundPercent = 50;
} else {
refundPercent = 100;
}
break;
case 'STRICT':
if (daysUntilStart > 7) {
refundPercent = 50;
} else {
refundPercent = 0;
}
break;
}
const refundAmount = Math.round((subtotal * refundPercent) / 100 * 100) / 100;
const depositRefund = depositAmount;
return { refundAmount, depositRefund };
}
/**
* Calculates the price breakdown for a booking.
*/
export function calculateBookingPrice(
pricePerPeriod: number,
totalPeriods: number,
commissionRate: number,
depositAmount: number
): { subtotal: number; commissionAmount: number; totalAmount: number } {
const subtotal = Math.round(pricePerPeriod * totalPeriods * 100) / 100;
const commissionAmount = Math.round(subtotal * (commissionRate / 100) * 100) / 100;
const totalAmount = Math.round((subtotal + commissionAmount + depositAmount) * 100) / 100;
return { subtotal, commissionAmount, totalAmount };
}
/**
* Performs lazy status transitions on a booking:
*
* - PENDING + expiresAt < now -> EXPIRED
* - CONFIRMED + startDate <= now -> ACTIVE
* - ACTIVE + endDate <= now -> COMPLETED
*
* Returns the updated booking if a transition occurred, or the original booking otherwise.
*/
export async function autoTransitionBooking(booking: Booking): Promise<Booking> {
const now = new Date();
let newStatus: BookingStatus | null = null;
if (booking.status === 'PENDING' && booking.expiresAt && booking.expiresAt < now) {
newStatus = 'EXPIRED';
} else if (booking.status === 'CONFIRMED' && booking.startDate <= now) {
newStatus = 'ACTIVE';
} else if (booking.status === 'ACTIVE' && booking.endDate <= now) {
newStatus = 'COMPLETED';
}
if (newStatus) {
return prisma.booking.update({
where: { id: booking.id },
data: { status: newStatus },
});
}
return booking;
}

View File

@@ -0,0 +1,35 @@
import { z } from 'zod';
export const banUserSchema = z.object({
reason: z.string().min(1, 'Ban reason is required').max(500),
});
export const changeRoleSchema = z.object({
role: z.enum(['USER', 'MODERATOR', 'ADMIN', 'SUPER_ADMIN']),
});
export const rejectListingSchema = z.object({
reason: z.string().min(1, 'Rejection reason is required').max(500),
});
export const resolveReportSchema = z.object({
status: z.enum(['RESOLVED', 'DISMISSED']),
resolution: z.string().max(500).optional(),
});
export const updateSettingsSchema = z.object({
listingFee: z.number().min(0).optional(),
commissionPercent: z.number().min(0).max(100).optional(),
autoApprove: z.boolean().optional(),
maxImagesPerListing: z.number().int().min(1).max(20).optional(),
maxListingsFreeTier: z.number().int().min(1).optional(),
proPrice: z.number().min(0).optional(),
businessPrice: z.number().min(0).optional(),
promotionDayPrice: z.number().min(0).optional(),
blockedKeywords: z.array(z.string()).optional(),
rentalCommissionPercent: z.number().min(0).max(100).optional(),
rentalAutoApprove: z.boolean().optional(),
maxRentalImagesPerListing: z.number().int().min(1).max(30).optional(),
bookingExpiryHours: z.number().int().min(1).max(720).optional(),
rentalPromotionDayPrice: z.number().min(0).optional(),
});

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const createBookingSchema = z.object({
rentalListingId: z.string().min(1),
periodType: z.enum(['DAILY', 'MONTHLY']),
startDate: z.string().datetime(),
endDate: z.string().datetime(),
message: z.string().max(1000).optional(),
});
export const rejectBookingSchema = z.object({
reason: z.string().min(1).max(500),
});
export const cancelBookingSchema = z.object({
reason: z.string().min(1).max(500),
});

View File

@@ -0,0 +1,11 @@
import { z } from 'zod';
export const createReviewSchema = z.object({
bookingId: z.string().min(1),
rating: z.number().int().min(1).max(5),
comment: z.string().max(2000).optional(),
});
export const respondReviewSchema = z.object({
response: z.string().min(1).max(1000),
});

View File

@@ -0,0 +1,39 @@
import { z } from 'zod';
export const createRentalSchema = z.object({
title: z.string().min(3).max(100),
description: z.string().min(10).max(5000),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']),
location: z.string().min(1).max(200),
dailyPrice: z.number().positive().optional(),
monthlyPrice: z.number().positive().optional(),
depositAmount: z.number().min(0).optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().optional(),
maxDays: z.number().int().positive().optional(),
minMonths: z.number().int().positive().optional(),
maxMonths: z.number().int().positive().optional(),
}).refine(data => data.dailyPrice || data.monthlyPrice, {
message: 'At least one price (daily or monthly) is required',
});
export const updateRentalSchema = z.object({
title: z.string().min(3).max(100).optional(),
description: z.string().min(10).max(5000).optional(),
category: z.enum(['APARTMENT', 'HOUSE', 'CAR', 'MOTORCYCLE', 'BICYCLE', 'EBIKE']).optional(),
location: z.string().min(1).max(200).optional(),
dailyPrice: z.number().positive().nullable().optional(),
monthlyPrice: z.number().positive().nullable().optional(),
depositAmount: z.number().min(0).nullable().optional(),
details: z.record(z.any()).optional(),
amenities: z.array(z.string()).optional(),
rules: z.array(z.string()).optional(),
cancellationPolicy: z.enum(['FLEXIBLE', 'MODERATE', 'STRICT']).optional(),
minDays: z.number().int().positive().nullable().optional(),
maxDays: z.number().int().positive().nullable().optional(),
minMonths: z.number().int().positive().nullable().optional(),
maxMonths: z.number().int().positive().nullable().optional(),
});

View File

@@ -0,0 +1,8 @@
import { z } from 'zod';
export const createReportSchema = z.object({
targetType: z.enum(['LISTING', 'USER']),
targetId: z.string().min(1),
reason: z.enum(['SPAM', 'INAPPROPRIATE', 'SCAM', 'COUNTERFEIT', 'PROHIBITED_ITEM', 'HARASSMENT', 'OTHER']),
description: z.string().max(1000).optional(),
});

View File

@@ -0,0 +1,319 @@
# Отчёт о реализации: Админ-панель, Модерация, Монетизация
**Дата**: 22 февраля 2026
**Проект**: Marketplace
**Статус**: Реализовано, собрано, мигрировано, seed обновлён
---
## Общий объём работ
- **29 новых файлов** создано
- **13 существующих файлов** модифицировано
- **42 файла** затронуто суммарно
- **1 новая зависимость** (`recharts`) установлена
- **1 миграция БД** применена (`admin_moderation_monetization`)
---
## Новые серверные файлы (16)
| # | Файл | Назначение | Статус |
|---|---|---|---|
| 1 | `server/src/middleware/requireRole.ts` | RBAC middleware — `requireModerator`, `requireAdmin`, `requireSuperAdmin` | Готово |
| 2 | `server/src/middleware/checkBanned.ts` | Проверка бана пользователя (403) | Готово |
| 3 | `server/src/utils/moderation.ts` | Кэш PlatformConfig (60с), проверка blockedKeywords | Готово |
| 4 | `server/src/routes/admin/index.ts` | Агрегатор всех админ-роутов с authenticate | Готово |
| 5 | `server/src/routes/admin/stats.ts` | GET /api/admin/stats — дашборд-аналитика | Готово |
| 6 | `server/src/routes/admin/users.ts` | CRUD юзеров, бан/разбан, смена ролей | Готово |
| 7 | `server/src/routes/admin/listings.ts` | Approve/reject листингов, featured, force delete | Готово |
| 8 | `server/src/routes/admin/reports.ts` | Управление жалобами — список, детали, resolve/dismiss | Готово |
| 9 | `server/src/routes/admin/moderation.ts` | Очередь PENDING_REVIEW + лог модерации | Готово |
| 10 | `server/src/routes/admin/payments.ts` | Все платежи + revenue breakdown по типам | Готово |
| 11 | `server/src/routes/admin/settings.ts` | CRUD PlatformConfig с инвалидацией кэша | Готово |
| 12 | `server/src/routes/report.ts` | POST /api/reports — создание жалобы юзером | Готово |
| 13 | `server/src/routes/subscription.ts` | Подписки: current, create, cancel | Готово |
| 14 | `server/src/routes/promotion.ts` | Продвижение листингов: promote, get status | Готово |
| 15 | `server/src/validators/admin.ts` | Zod-схемы: ban, role, reject, resolve, settings | Готово |
| 16 | `server/src/validators/report.ts` | Zod-схема создания жалобы | Готово |
---
## Новые клиентские файлы (13)
| # | Файл | Назначение | Статус |
|---|---|---|---|
| 17 | `client/src/components/layout/RequireRole.tsx` | Route guard по роли пользователя | Готово |
| 18 | `client/src/components/layout/AdminLayout.tsx` | Sidebar-layout админки с навигацией | Готово |
| 19 | `client/src/components/ui/StatCard.tsx` | Карточка метрики (иконка, число, цвет) | Готово |
| 20 | `client/src/components/ui/DataTable.tsx` | Переиспользуемая таблица с поиском и пагинацией | Готово |
| 21 | `client/src/components/ReportModal.tsx` | Модалка жалобы с радиокнопками причин | Готово |
| 22 | `client/src/pages/admin/AdminDashboardPage.tsx` | Дашборд: 5 метрик + Quick Stats + Health | Готово |
| 23 | `client/src/pages/admin/AdminUsersPage.tsx` | Таблица юзеров с поиском и навигацией | Готово |
| 24 | `client/src/pages/admin/AdminUserDetailPage.tsx` | Детали юзера: профиль, статы, бан, роль, история | Готово |
| 25 | `client/src/pages/admin/AdminListingsPage.tsx` | Таблица листингов с табами по статусу | Готово |
| 26 | `client/src/pages/admin/AdminReportsPage.tsx` | Жалобы с табами + модалка resolve/dismiss | Готово |
| 27 | `client/src/pages/admin/AdminModerationPage.tsx` | Очередь модерации — карточки с approve/reject | Готово |
| 28 | `client/src/pages/admin/AdminPaymentsPage.tsx` | 4 StatCard выручки + таблица транзакций | Готово |
| 29 | `client/src/pages/admin/AdminSettingsPage.tsx` | Форма настроек: fees, limits, moderation | Готово |
---
## Модифицированные файлы (13)
| # | Файл | Что изменено | Статус |
|---|---|---|---|
| 30 | `server/prisma/schema.prisma` | 8 новых enum, 6 новых моделей, 3 модели расширены | Готово |
| 31 | `server/prisma/seed.ts` | + PlatformConfig, + роли (alice=SA, bob=A, carol=M), + cleanup новых таблиц | Готово |
| 32 | `server/src/index.ts` | + 4 новых route group: reports, subscriptions, promotions, admin | Готово |
| 33 | `server/src/middleware/auth.ts` | + `userRole?: string` в Request type | Готово |
| 34 | `server/src/routes/auth.ts` | + `role` в select (3 места), + проверка `isBanned` при логине | Готово |
| 35 | `server/src/routes/listing.ts` | + autoApprove check, + blockedKeywords check, + isFeatured в select | Готово |
| 36 | `server/src/routes/payment.ts` | + динамический listingFee из PlatformConfig, + type: LISTING_FEE | Готово |
| 37 | `server/src/routes/offer.ts` | + создание Commission payment при ACCEPTED | Готово |
| 38 | `client/src/types/index.ts` | + role в User, + PENDING_REVIEW, + новые NotificationType, + 5 новых интерфейсов | Готово |
| 39 | `client/src/context/AuthContext.tsx` | + isAdmin, isModerator, isSuperAdmin | Готово |
| 40 | `client/src/router.tsx` | + /admin/* routes (8 маршрутов) | Готово |
| 41 | `client/src/components/layout/Header.tsx` | + ссылка "Admin" с иконкой Shield | Готово |
| 42 | `client/src/pages/ProductDetailPage.tsx` | + ReportModal вместо alert() | Готово |
| + | `client/src/pages/NotificationsPage.tsx` | + иконки и цвета для 6 новых NotificationType | Готово |
---
## Схема БД — новые enum'ы
```
UserRole: USER | MODERATOR | ADMIN | SUPER_ADMIN
ReportReason: SPAM | INAPPROPRIATE | SCAM | COUNTERFEIT | PROHIBITED_ITEM | HARASSMENT | OTHER
ReportStatus: OPEN | REVIEWING | RESOLVED | DISMISSED
ReportTargetType: LISTING | USER
SubscriptionTier: BASIC | PRO | BUSINESS
SubscriptionStatus: ACTIVE | CANCELLED | EXPIRED | PAST_DUE
PaymentType: LISTING_FEE | COMMISSION | PROMOTION | SUBSCRIPTION
ModerationAction: APPROVED | REJECTED | WARNING | BAN | UNBAN | LISTING_DELETED | LISTING_FEATURED
```
---
## Схема БД — новые модели
### Report
```
id, reporterId → User, targetType (LISTING|USER), targetId, reason, description?,
status (OPEN→REVIEWING→RESOLVED|DISMISSED), resolvedBy?, resolution?
```
### PlatformConfig (singleton)
```
listingFee ($5), commissionPercent (5%), autoApprove (true),
maxImagesPerListing (6), maxListingsFreeTier (5),
proPrice ($9.99), businessPrice ($29.99), promotionDayPrice ($2.99),
blockedKeywords []
```
### Subscription
```
userId (unique) → User, tier (BASIC|PRO|BUSINESS), status,
stripeSubscriptionId?, currentPeriodEnd?
```
### PromotedListing
```
listingId (unique) → Listing, userId → User, startDate, endDate,
amountPaid, isActive
```
### ModerationLog
```
moderatorId → User, targetUserId?, targetListingId?,
action (ModerationAction), reason?, details (Json?)
```
---
## API Endpoints — полный список
### Admin Stats
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/stats` | MODERATOR+ |
| GET | `/api/admin/stats/revenue` | ADMIN+ |
| GET | `/api/admin/stats/users` | ADMIN+ |
| GET | `/api/admin/stats/listings` | MODERATOR+ |
### Admin Users
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/users` | MODERATOR+ |
| GET | `/api/admin/users/:id` | MODERATOR+ |
| PATCH | `/api/admin/users/:id/role` | SUPER_ADMIN |
| POST | `/api/admin/users/:id/ban` | ADMIN+ |
| POST | `/api/admin/users/:id/unban` | ADMIN+ |
### Admin Listings
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/listings` | MODERATOR+ |
| POST | `/api/admin/listings/:id/approve` | MODERATOR+ |
| POST | `/api/admin/listings/:id/reject` | MODERATOR+ |
| DELETE | `/api/admin/listings/:id` | ADMIN+ |
| POST | `/api/admin/listings/:id/feature` | ADMIN+ |
### Admin Reports
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/reports` | MODERATOR+ |
| GET | `/api/admin/reports/:id` | MODERATOR+ |
| PATCH | `/api/admin/reports/:id` | MODERATOR+ |
### Admin Moderation
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/moderation/queue` | MODERATOR+ |
| GET | `/api/admin/moderation/logs` | ADMIN+ |
### Admin Payments
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/payments` | ADMIN+ |
| GET | `/api/admin/payments/revenue` | ADMIN+ |
### Admin Settings
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/admin/settings` | ADMIN+ |
| PATCH | `/api/admin/settings` | SUPER_ADMIN |
### User Reports
| Method | Path | Доступ |
|---|---|---|
| POST | `/api/reports` | Авторизованный |
### Subscriptions
| Method | Path | Доступ |
|---|---|---|
| GET | `/api/subscriptions/current` | Авторизованный |
| POST | `/api/subscriptions/create` | Авторизованный |
| POST | `/api/subscriptions/cancel` | Авторизованный |
### Promotions
| Method | Path | Доступ |
|---|---|---|
| POST | `/api/listings/:id/promote` | Авторизованный |
| GET | `/api/listings/:id/promotion` | Авторизованный |
---
## Роли и права
| Действие | USER | MODERATOR | ADMIN | SUPER_ADMIN |
|---|---|---|---|---|
| Просмотр дашборда | - | + | + | + |
| Просмотр юзеров | - | + | + | + |
| Бан/разбан | - | - | + | + |
| Смена роли | - | - | - | + |
| Approve/Reject листинг | - | + | + | + |
| Force delete листинг | - | - | + | + |
| Feature листинг | - | - | + | + |
| Управление жалобами | - | + | + | + |
| Просмотр платежей | - | - | + | + |
| Настройки платформы (чтение) | - | - | + | + |
| Настройки платформы (запись) | - | - | - | + |
| Создание жалобы | + | + | + | + |
| Подписки | + | + | + | + |
| Продвижение | + | + | + | + |
---
## Тестовые данные (seed)
### Пользователи с ролями
| Пользователь | Email | Роль | Пароль |
|---|---|---|---|
| Alice Chen | alice.chen@example.com | SUPER_ADMIN | password123 |
| Bob Martinez | bob.martinez@example.com | ADMIN | password123 |
| Carol Nguyen | carol.nguyen@example.com | MODERATOR | password123 |
| David Kim | david.kim@example.com | USER | password123 |
| Eva Johnson | eva.johnson@example.com | USER | password123 |
### PlatformConfig (дефолты)
- Listing Fee: $5.00
- Commission: 5%
- Auto-Approve: true (включено)
- Max Images: 6
- Free Tier Limit: 5 листингов
- Pro Price: $9.99/мес
- Business Price: $29.99/мес
- Promotion Day Price: $2.99
- Blocked Keywords: `illegal`, `drugs`, `weapons`
---
## Миграция данных — обратная совместимость
| Поле | Default | Влияние на старые данные |
|---|---|---|
| `User.role` | `USER` | Все существующие юзеры → USER |
| `User.isBanned` | `false` | Никто не затронут |
| `ListingStatus.PENDING_REVIEW` | — | Аддитивно, старые данные не ломаются |
| `Payment.type` | `LISTING_FEE` | Корректно для всех существующих платежей |
| `PlatformConfig.autoApprove` | `true` | Текущее поведение не меняется |
---
## Проверка сборки
| Проверка | Результат |
|---|---|
| `npx tsc --noEmit` (server) | Без ошибок |
| `npx tsc --noEmit` (client) | Без ошибок |
| `npm run build --workspace=client` | Успешно (432 KB JS, 47 KB CSS) |
| `npx prisma migrate dev` | Миграция применена |
| `npx tsx prisma/seed.ts` | Seed выполнен |
| Health check `/api/health` | `{"status":"ok"}` |
---
## Инструкции по деплою
```bash
# На сервере 173.212.212.157:
# 1. Обновить код
cd /var/www/marketplace-app && git pull
# 2. Установить зависимости
npm install
# 3. Применить миграцию
cd server && npx prisma db push
# 4. Обновить seed (опционально)
npx tsx prisma/seed.ts
# 5. Собрать клиент
cd .. && npm run build --workspace=client
# 6. Скопировать билд
cp -r client/dist/* /var/www/marketplace/
# 7. Перезапустить сервер
pm2 restart marketplace-api
```
---
## Верификация на проде
1. Логин как `alice.chen@example.com` (SUPER_ADMIN) → в хедере видна ссылка "Admin"
2. `/admin` → дашборд с 5 метриками
3. `/admin/users` → таблица юзеров с ролями и статусами
4. `/admin/users/:id` → детали юзера, бан david → логин как david → 403
5. Разбанить david → логин работает
6. `/admin/settings` → отключить autoApprove
7. Создать листинг → статус PENDING_REVIEW
8. `/admin/moderation` → approve → листинг активен
9. На странице товара → Report → модалка → отправить
10. `/admin/reports` → resolve жалобу
11. `/admin/payments` → транзакции по типам
12. `/admin/settings` → изменить listing fee → проверить

View File

@@ -0,0 +1,274 @@
# Marketplace — Админ-панель, Модерация, Монетизация
## Контекст
MVP маркетплейса полностью рабочий и задеплоен (buyer/seller flow, чат, офферы, нотификации, избранное, настройки). Но отсутствует вся бизнес-логика уровня продакшена: нет ролей, нет админки, нет модерации, нет системы жалоб, нет монетизации. Сейчас любой листинг публикуется мгновенно без проверки, нет возможности заблокировать пользователя со стороны платформы, и нет инструментов для управления маркетплейсом.
**Сервер**: 173.212.212.157 | **Домен**: marketplace.173.212.212.157.sslip.io
---
## Фаза 1: Схема БД + RBAC (Роли и права)
### Prisma Schema — `server/prisma/schema.prisma`
**Новые enum'ы:**
- `UserRole`: USER, MODERATOR, ADMIN, SUPER_ADMIN
- `ReportReason`: SPAM, INAPPROPRIATE, SCAM, COUNTERFEIT, PROHIBITED_ITEM, HARASSMENT, OTHER
- `ReportStatus`: OPEN, REVIEWING, RESOLVED, DISMISSED
- `ReportTargetType`: LISTING, USER
- `SubscriptionTier`: BASIC, PRO, BUSINESS
- `SubscriptionStatus`: ACTIVE, CANCELLED, EXPIRED, PAST_DUE
- `PaymentType`: LISTING_FEE, COMMISSION, PROMOTION, SUBSCRIPTION
- `ModerationAction`: APPROVED, REJECTED, WARNING, BAN, UNBAN, LISTING_DELETED, LISTING_FEATURED
**Изменения существующих enum'ов:**
- `ListingStatus`: добавлен `PENDING_REVIEW` между DRAFT и ACTIVE
- `NotificationType`: добавлены `LISTING_APPROVED, LISTING_REJECTED, MODERATION_WARNING, ACCOUNT_BANNED, ACCOUNT_UNBANNED, REPORT_RESOLVED`
**Изменения существующих моделей:**
- `User`: + `role UserRole @default(USER)`, `isBanned Boolean @default(false)`, `banReason String?`, `bannedAt DateTime?`, `bannedBy String?`
- `Listing`: + `isFeatured Boolean @default(false)`, `rejectionReason String?`, `reviewedBy String?`, `reviewedAt DateTime?`
- `Payment`: + `type PaymentType @default(LISTING_FEE)`, `description String?`
**Новые модели (6 штук):**
| Модель | Назначение | Ключевые поля |
|---|---|---|
| `Report` | Жалобы пользователей | reporterId, targetType, targetId, reason, status, resolvedBy, resolution |
| `PlatformConfig` | Настройки платформы (1 строка) | listingFee, commissionPercent, autoApprove, maxImagesPerListing, maxListingsFreeTier, proPrice, businessPrice, promotionDayPrice, blockedKeywords[] |
| `Subscription` | Подписки продавцов | userId (unique), tier, status, stripeSubscriptionId, currentPeriodEnd |
| `PromotedListing` | Продвинутые объявления | listingId (unique), userId, startDate, endDate, amountPaid, isActive |
| `ModerationLog` | Аудит-лог действий модераторов | moderatorId, targetUserId?, targetListingId?, action, reason, details(Json) |
### Middleware — новые файлы
| Файл | Назначение |
|---|---|
| `server/src/middleware/requireRole.ts` | Фабрика middleware: `requireRole('ADMIN', 'SUPER_ADMIN')`. Проверяет роль из БД (не из JWT — изменения роли применяются мгновенно). Экспорт: `requireModerator`, `requireAdmin`, `requireSuperAdmin` |
| `server/src/middleware/checkBanned.ts` | Проверяет `isBanned` и возвращает 403 если забанен |
| `server/src/utils/moderation.ts` | Проверка текста на запрещённые слова из PlatformConfig. Кэш конфига на 60 сек |
### Изменения существующих файлов
| Файл | Изменения |
|---|---|
| `server/src/middleware/auth.ts` | Расширен `Request` типом `userRole?: string` |
| `server/src/routes/auth.ts` | Добавлен `role` в select; проверка `isBanned` при логине (403 с причиной) |
| `client/src/types/index.ts` | Добавлен `role: UserRole` в User, новые типы Report, PlatformConfig, Subscription и т.д. |
| `client/src/context/AuthContext.tsx` | Добавлены `isAdmin`, `isModerator`, `isSuperAdmin` (computed из `user.role`) |
### Seed — `server/prisma/seed.ts`
- Создана строка PlatformConfig с дефолтами
- alice → SUPER_ADMIN, bob → ADMIN, carol → MODERATOR
---
## Фаза 2: Админ-панель — Layout + Dashboard
### Новые компоненты
| Файл | Назначение |
|---|---|
| `client/src/components/layout/RequireRole.tsx` | Route guard: проверяет `user.role`, редирект на `/` если нет доступа |
| `client/src/components/layout/AdminLayout.tsx` | Sidebar с навигацией (Dashboard, Users, Listings, Reports, Moderation, Payments, Settings) + `<Outlet />` |
| `client/src/components/ui/StatCard.tsx` | Карточка метрики: иконка, число, подпись, тренд |
| `client/src/components/ui/DataTable.tsx` | Переиспользуемая таблица: колонки, пагинация, поиск, сортировка |
### Backend — `server/src/routes/admin/stats.ts`
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/stats` | MODERATOR+ | Общая статистика: юзеры, листинги, офферы, выручка, активные сегодня |
| `GET /api/admin/stats/revenue` | ADMIN+ | Выручка по дням/неделям/месяцам |
| `GET /api/admin/stats/users` | ADMIN+ | Рост пользователей по времени |
| `GET /api/admin/stats/listings` | MODERATOR+ | Активность листингов по времени |
### Frontend — `client/src/pages/admin/AdminDashboardPage.tsx`
- 5 StatCard'ов: юзеры, листинги, офферы, выручка, активные
- Quick Stats и Platform Health панели
### Router — `client/src/router.tsx`
```
/admin → RequireRole(['MODERATOR','ADMIN','SUPER_ADMIN']) → AdminLayout
/admin (index) → AdminDashboardPage
/admin/users → AdminUsersPage
/admin/users/:id → AdminUserDetailPage
/admin/listings → AdminListingsPage
/admin/reports → AdminReportsPage
/admin/moderation → AdminModerationPage
/admin/payments → AdminPaymentsPage
/admin/settings → AdminSettingsPage
```
### Header — `client/src/components/layout/Header.tsx`
- Добавлена ссылка "Admin" в навигацию (если `isModerator || isAdmin`)
---
## Фаза 3: Управление пользователями
### Backend — `server/src/routes/admin/users.ts`
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/users` | MODERATOR+ | Список юзеров с поиском, фильтром по роли/статусу, пагинацией |
| `GET /api/admin/users/:id` | MODERATOR+ | Полный профиль + кол-во листингов/офферов/жалоб + лог модерации |
| `PATCH /api/admin/users/:id/role` | SUPER_ADMIN | Сменить роль (нельзя себя понизить) |
| `POST /api/admin/users/:id/ban` | ADMIN+ | Забанить (body: `{ reason }`). Создаёт ModerationLog, отправляет нотификацию ACCOUNT_BANNED |
| `POST /api/admin/users/:id/unban` | ADMIN+ | Разбанить. ModerationLog + нотификация ACCOUNT_UNBANNED |
### Frontend
- `AdminUsersPage.tsx` — DataTable: аватар+имя, email, роль (Badge), статус, дата регистрации, действия
- `AdminUserDetailPage.tsx` — профиль + статистика + история модерации + кнопки бан/разбан/роль
---
## Фаза 4: Модерация объявлений
### Backend
**`server/src/routes/admin/listings.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/listings` | MODERATOR+ | Все листинги, фильтр по статусу/категории, поиск |
| `POST /api/admin/listings/:id/approve` | MODERATOR+ | Статус → ACTIVE, ModerationLog, нотификация LISTING_APPROVED |
| `POST /api/admin/listings/:id/reject` | MODERATOR+ | Статус → DELETED, сохранить rejectionReason, нотификация LISTING_REJECTED |
| `DELETE /api/admin/listings/:id` | ADMIN+ | Принудительное удаление, ModerationLog |
| `POST /api/admin/listings/:id/feature` | ADMIN+ | Переключить isFeatured |
**`server/src/routes/admin/moderation.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/moderation/queue` | MODERATOR+ | Листинги со статусом PENDING_REVIEW, пагинация |
| `GET /api/admin/moderation/logs` | ADMIN+ | Полная история действий модерации |
**Изменения `server/src/routes/listing.ts`:**
- `POST /:id/activate`: если `PlatformConfig.autoApprove === false` → статус `PENDING_REVIEW` вместо `ACTIVE`
- `POST /` (создание): проверка текста на `blockedKeywords` → если найдено, автоматически `PENDING_REVIEW`
- Добавлен `isFeatured` в listingSelect
### Frontend
- `AdminListingsPage.tsx` — DataTable с табами по статусу (ALL, PENDING_REVIEW, ACTIVE, SOLD, DELETED)
- `AdminModerationPage.tsx` — Карточки листингов на ревью: фото, заголовок, описание, продавец, кнопки Approve/Reject
---
## Фаза 5: Система жалоб (Reports)
### Backend
**`server/src/routes/report.ts`** (пользовательский):
| Endpoint | Доступ | Что делает |
|---|---|---|
| `POST /api/reports` | Авторизованный | Создать жалобу: `{ targetType, targetId, reason, description }` |
**`server/src/routes/admin/reports.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/reports` | MODERATOR+ | Список жалоб, фильтр по статусу/типу, пагинация |
| `GET /api/admin/reports/:id` | MODERATOR+ | Детали жалобы с информацией о цели |
| `PATCH /api/admin/reports/:id` | MODERATOR+ | Разрешить/отклонить: `{ status, resolution }` |
### Frontend
- `ReportModal.tsx` — Модалка с радиокнопками причин + описание
- `ProductDetailPage.tsx` — кнопка "Report" теперь открывает ReportModal (вместо `alert()`)
- `AdminReportsPage.tsx` — DataTable с табами по статусу
---
## Фаза 6: Настройки платформы + Админ платежей
### Backend
**`server/src/routes/admin/settings.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/settings` | ADMIN+ | Текущий PlatformConfig |
| `PATCH /api/admin/settings` | SUPER_ADMIN | Обновить любые поля PlatformConfig |
**`server/src/routes/admin/payments.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/admin/payments` | ADMIN+ | Все платежи, фильтр по типу/статусу, пагинация |
| `GET /api/admin/payments/revenue` | ADMIN+ | Разбивка: listing fees, commissions, promotions, subscriptions |
**Изменения `server/src/routes/payment.ts`:**
- `POST /create-intent`: читает `listingFee` из PlatformConfig вместо хардкода `500`
- Записывает `type: 'LISTING_FEE'` в Payment
### Frontend
- `AdminSettingsPage.tsx` — Форма: listing fee, commission %, auto-approve toggle, max images, free tier limit, pro/business цены, promotion day price, blocked keywords
- `AdminPaymentsPage.tsx` — 4 карточки выручки по типам + таблица транзакций
---
## Фаза 7: Монетизация — Подписки + Продвижение
### Backend
**`server/src/routes/subscription.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `GET /api/subscriptions/current` | Авторизованный | Текущая подписка (или BASIC по умолчанию) |
| `POST /api/subscriptions/create` | Авторизованный | Создать подписку PRO/BUSINESS |
| `POST /api/subscriptions/cancel` | Авторизованный | Отменить подписку |
**`server/src/routes/promotion.ts`:**
| Endpoint | Доступ | Что делает |
|---|---|---|
| `POST /api/listings/:id/promote` | Авторизованный | Оплатить продвижение (body: `{ days }`) |
| `GET /api/listings/:id/promotion` | Авторизованный | Статус продвижения |
**Изменения `server/src/routes/offer.ts`:**
- При ACCEPTED: создаётся Payment с `type: 'COMMISSION'` и суммой = `commissionPercent` от amount
**Тарифные планы:**
- **Basic** (бесплатно): до 5 листингов/мес
- **Pro** ($9.99/мес): безлимит + аналитика продавца
- **Business** ($29.99/мес): bulk-операции + приоритетная поддержка
---
## Деплой
```bash
# 1. Миграция
cd server && npx prisma migrate dev --name admin_moderation_monetization
# 2. Обновить seed
npx tsx prisma/seed.ts
# 3. Билд и деплой
npm run build --workspace=client
scp -r client/dist/* root@173.212.212.157:/var/www/marketplace/
ssh root@173.212.212.157 'cd /var/www/marketplace-app && git pull && cd server && npm install && npx prisma db push && pm2 restart marketplace-api'
# 4. Seed на проде
ssh root@173.212.212.157 'cd /var/www/marketplace-app/server && npx tsx prisma/seed.ts'
```
---
## Верификация
1. Логин как alice (SUPER_ADMIN) → в хедере видна ссылка "Admin"
2. `/admin` → дашборд с метриками
3. `/admin/users` → таблица юзеров, можно забанить david
4. Логин как david → получить 403 "Account suspended"
5. `/admin/users` → разбанить david
6. `/admin/settings` → отключить autoApprove
7. Логин как david → создать листинг → статус PENDING_REVIEW
8. `/admin/moderation` → увидеть листинг в очереди → Approve
9. На странице товара нажать Report → модалка → отправить жалобу
10. `/admin/reports` → увидеть жалобу → Resolve
11. `/admin/payments` → все транзакции с типами
12. `/admin/settings` → изменить listing fee

View File

@@ -0,0 +1,93 @@
# Полный список файлов — Админ-панель, Модерация, Монетизация
## Новые серверные файлы (16)
```
server/src/middleware/requireRole.ts — RBAC middleware
server/src/middleware/checkBanned.ts — Проверка бана
server/src/utils/moderation.ts — Кэш конфига + keyword check
server/src/routes/admin/index.ts — Агрегатор админ-роутов
server/src/routes/admin/stats.ts — Аналитика дашборда
server/src/routes/admin/users.ts — Управление юзерами
server/src/routes/admin/listings.ts — Управление листингами
server/src/routes/admin/reports.ts — Управление жалобами
server/src/routes/admin/moderation.ts — Очередь модерации
server/src/routes/admin/payments.ts — Платежи и выручка
server/src/routes/admin/settings.ts — Настройки платформы
server/src/routes/report.ts — Создание жалобы (юзер)
server/src/routes/subscription.ts — Подписки
server/src/routes/promotion.ts — Продвижение листингов
server/src/validators/admin.ts — Zod-схемы для админки
server/src/validators/report.ts — Zod-схема жалобы
```
## Новые клиентские файлы (13)
```
client/src/components/layout/RequireRole.tsx — Route guard по роли
client/src/components/layout/AdminLayout.tsx — Sidebar-layout админки
client/src/components/ui/StatCard.tsx — Карточка метрики
client/src/components/ui/DataTable.tsx — Таблица с пагинацией
client/src/components/ReportModal.tsx — Модалка жалобы
client/src/pages/admin/AdminDashboardPage.tsx — Дашборд
client/src/pages/admin/AdminUsersPage.tsx — Юзеры
client/src/pages/admin/AdminUserDetailPage.tsx — Детали юзера
client/src/pages/admin/AdminListingsPage.tsx — Листинги
client/src/pages/admin/AdminReportsPage.tsx — Жалобы
client/src/pages/admin/AdminModerationPage.tsx — Очередь модерации
client/src/pages/admin/AdminPaymentsPage.tsx — Платежи
client/src/pages/admin/AdminSettingsPage.tsx — Настройки
```
## Модифицированные файлы (13)
```
server/prisma/schema.prisma — 8 enum + 6 моделей + расширение 3 моделей
server/prisma/seed.ts — PlatformConfig + роли + cleanup
server/src/index.ts — 4 новых route group
server/src/middleware/auth.ts — userRole в Request
server/src/routes/auth.ts — role в select + isBanned check
server/src/routes/listing.ts — autoApprove + keywords + isFeatured
server/src/routes/payment.ts — динамический listingFee
server/src/routes/offer.ts — commission при accept
client/src/types/index.ts — role + новые типы
client/src/context/AuthContext.tsx — isAdmin/isModerator/isSuperAdmin
client/src/router.tsx — /admin/* routes
client/src/components/layout/Header.tsx — ссылка Admin
client/src/pages/ProductDetailPage.tsx — ReportModal
client/src/pages/NotificationsPage.tsx — новые notification types
```
## Итого
- **29 новых файлов**
- **13+1 изменённых файлов**
- **43 файла** затронуто
## Структура папки admin routes
```
server/src/routes/admin/
├── index.ts — authenticate + mount sub-routers
├── stats.ts — GET /stats, /stats/revenue, /stats/users, /stats/listings
├── users.ts — GET/PATCH/POST users, ban, unban, role
├── listings.ts — GET listings, approve, reject, delete, feature
├── reports.ts — GET/PATCH reports
├── moderation.ts — GET queue, GET logs
├── payments.ts — GET payments, GET revenue
└── settings.ts — GET/PATCH settings
```
## Структура папки admin pages
```
client/src/pages/admin/
├── AdminDashboardPage.tsx
├── AdminUsersPage.tsx
├── AdminUserDetailPage.tsx
├── AdminListingsPage.tsx
├── AdminReportsPage.tsx
├── AdminModerationPage.tsx
├── AdminPaymentsPage.tsx
└── AdminSettingsPage.tsx
```