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