feat: subscription tiers, period filter, dashboards, docs
- Add subscription tiers (Basic/Pro/Business) with listing limits and dynamic commission - Add daily/monthly period filter on rentals page - Add landlord dashboard with earnings chart, stat cards, property performance - Add landlord subscription management page - Add tenant dashboard with upcoming stays - Add business model documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
data: any;
|
||||||
|
constructor(message: string, status: number, data?: any) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.status = status;
|
||||||
|
this.data = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private accessToken: string | null = null;
|
private accessToken: string | null = null;
|
||||||
|
|
||||||
@@ -25,7 +36,7 @@ class ApiClient {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||||||
throw new Error(error.message || `HTTP ${response.status}`);
|
throw new ApiError(error.message || `HTTP ${response.status}`, response.status, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|||||||
57
client/src/components/charts/EarningsChart.tsx
Normal file
57
client/src/components/charts/EarningsChart.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
import type { Booking } from '../../types/rental';
|
||||||
|
|
||||||
|
interface EarningsChartProps {
|
||||||
|
bookings: Booking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EarningsChart({ bookings }: EarningsChartProps) {
|
||||||
|
const completedBookings = bookings.filter(b => b.status === 'COMPLETED');
|
||||||
|
|
||||||
|
// Group by month
|
||||||
|
const monthlyData: Record<string, number> = {};
|
||||||
|
completedBookings.forEach(b => {
|
||||||
|
const date = new Date(b.endDate || b.createdAt);
|
||||||
|
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
monthlyData[key] = (monthlyData[key] || 0) + b.totalAmount;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get last 6 months
|
||||||
|
const months: { month: string; earnings: number }[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = 5; i >= 0; i--) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const label = d.toLocaleDateString('en-US', { month: 'short', year: '2-digit' });
|
||||||
|
months.push({ month: label, earnings: monthlyData[key] || 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData = months.some(m => m.earnings > 0);
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Monthly Earnings</h3>
|
||||||
|
<p className="text-sm text-gray-400 py-8 text-center">No completed bookings yet</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Monthly Earnings</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={250}>
|
||||||
|
<BarChart data={months}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 12, fill: '#9ca3af' }} />
|
||||||
|
<YAxis tick={{ fontSize: 12, fill: '#9ca3af' }} tickFormatter={v => `$${v}`} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => [`$${Number(value).toFixed(2)}`, 'Earnings']}
|
||||||
|
contentStyle={{ borderRadius: '12px', border: '1px solid #e5e7eb' }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="earnings" fill="#8b5cf6" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { LayoutDashboard, List, Calendar, CalendarDays, DollarSign, Star } from 'lucide-react';
|
import { LayoutDashboard, List, Calendar, CalendarDays, DollarSign, Star, CreditCard } from 'lucide-react';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/landlord', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
{ to: '/landlord', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||||
@@ -8,6 +8,7 @@ const navItems = [
|
|||||||
{ to: '/landlord/calendar', icon: CalendarDays, label: 'Calendar' },
|
{ to: '/landlord/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||||
{ to: '/landlord/payouts', icon: DollarSign, label: 'Payouts' },
|
{ to: '/landlord/payouts', icon: DollarSign, label: 'Payouts' },
|
||||||
{ to: '/landlord/reviews', icon: Star, label: 'Reviews' },
|
{ to: '/landlord/reviews', icon: Star, label: 'Reviews' },
|
||||||
|
{ to: '/landlord/subscription', icon: CreditCard, label: 'Subscription' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LandlordLayout() {
|
export function LandlordLayout() {
|
||||||
|
|||||||
71
client/src/components/rentals/UpcomingStays.tsx
Normal file
71
client/src/components/rentals/UpcomingStays.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Calendar, MapPin } from 'lucide-react';
|
||||||
|
import { Badge } from '../ui/Badge';
|
||||||
|
import type { Booking } from '../../types/rental';
|
||||||
|
|
||||||
|
interface UpcomingStaysProps {
|
||||||
|
bookings: Booking[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCountdown(dateStr: string): string {
|
||||||
|
const diff = new Date(dateStr).getTime() - Date.now();
|
||||||
|
if (diff <= 0) return 'Started';
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
if (days > 0) return `in ${days} day${days === 1 ? '' : 's'}`;
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
if (hours > 0) return `in ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
return `in ${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpcomingStays({ bookings }: UpcomingStaysProps) {
|
||||||
|
const upcoming = bookings
|
||||||
|
.filter(b => b.status === 'CONFIRMED' || b.status === 'ACTIVE')
|
||||||
|
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||||
|
|
||||||
|
if (upcoming.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Upcoming Stays</h3>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||||
|
{upcoming.map((booking) => (
|
||||||
|
<Link
|
||||||
|
key={booking.id}
|
||||||
|
to={`/rentals/${booking.rentalListingId}`}
|
||||||
|
className="flex-shrink-0 w-72 bg-white rounded-2xl border border-gray-200 overflow-hidden hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="h-32 bg-gray-100 relative">
|
||||||
|
{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-gradient-to-br from-violet-100 to-pink-100" />
|
||||||
|
)}
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<Badge variant={booking.status === 'ACTIVE' ? 'success' : 'info'} size="sm">
|
||||||
|
{booking.status === 'ACTIVE' ? 'Active' : 'Confirmed'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="font-medium text-gray-900 truncate">{booking.rentalListing.title}</p>
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500 mt-1">
|
||||||
|
<MapPin className="w-3 h-3" />
|
||||||
|
{booking.rentalListing.location}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-500">
|
||||||
|
<Calendar className="w-3 h-3" />
|
||||||
|
{new Date(booking.startDate).toLocaleDateString()} - {new Date(booking.endDate).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-violet-600">{getCountdown(booking.startDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Calendar, DollarSign, Clock, Star } from 'lucide-react';
|
||||||
import { DataTable } from '../components/ui/DataTable';
|
import { DataTable } from '../components/ui/DataTable';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { GradientButton } from '../components/ui/GradientButton';
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
|
import { StatCard } from '../components/ui/StatCard';
|
||||||
import { BookingStatusBadge } from '../components/rentals/BookingStatusBadge';
|
import { BookingStatusBadge } from '../components/rentals/BookingStatusBadge';
|
||||||
|
import { UpcomingStays } from '../components/rentals/UpcomingStays';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import { formatCurrency } from '../utils/format';
|
import { formatCurrency } from '../utils/format';
|
||||||
import type { Booking, BookingStatus } from '../types/rental';
|
import type { Booking, BookingStatus } from '../types/rental';
|
||||||
@@ -85,10 +88,31 @@ export function MyBookingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const totalBookingsCount = bookings.length;
|
||||||
|
const upcomingCount = bookings.filter(b => b.status === 'CONFIRMED' || b.status === 'ACTIVE').length;
|
||||||
|
const totalSpent = bookings
|
||||||
|
.filter(b => ['COMPLETED', 'CONFIRMED', 'ACTIVE'].includes(b.status))
|
||||||
|
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||||
|
const completedWithReview = bookings.filter(b => b.status === 'COMPLETED' && b.review);
|
||||||
|
const avgRating = completedWithReview.length > 0
|
||||||
|
? completedWithReview.reduce((sum, b) => sum + (b.review?.rating || 0), 0) / completedWithReview.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">My Bookings</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">My Bookings</h1>
|
||||||
|
|
||||||
|
{/* Stat Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<StatCard icon={Calendar} label="Total Bookings" value={totalBookingsCount} color="blue" />
|
||||||
|
<StatCard icon={Clock} label="Upcoming" value={upcomingCount} color="green" />
|
||||||
|
<StatCard icon={DollarSign} label="Total Spent" value={formatCurrency(totalSpent)} color="pink" />
|
||||||
|
<StatCard icon={Star} label="Avg Rating Given" value={avgRating ? avgRating.toFixed(1) : 'N/A'} color="yellow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upcoming Stays */}
|
||||||
|
<UpcomingStays bookings={bookings} />
|
||||||
|
|
||||||
{/* Status filter tabs */}
|
{/* Status filter tabs */}
|
||||||
<div className="flex gap-2 mb-4 flex-wrap">
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
{STATUS_TABS.map((t) => (
|
{STATUS_TABS.map((t) => (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, ChevronLeft, ChevronRight, Lock } from 'lucide-react';
|
||||||
import { Card } from '../components/ui/Card';
|
import { Card } from '../components/ui/Card';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { Badge } from '../components/ui/Badge';
|
import { Badge } from '../components/ui/Badge';
|
||||||
@@ -14,6 +14,28 @@ import { useAuth } from '../context/AuthContext';
|
|||||||
import { formatDate } from '../utils/format';
|
import { formatDate } from '../utils/format';
|
||||||
import type { RentalListing, RentalReview } from '../types/rental';
|
import type { RentalListing, RentalReview } from '../types/rental';
|
||||||
|
|
||||||
|
function LoginGate() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-col items-center py-6 text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary-50 flex items-center justify-center mb-3">
|
||||||
|
<Lock className="w-6 h-6 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-1">Sign in to view full details</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">Create an account or log in to see the complete listing, amenities, reviews, and more.</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link to="/login">
|
||||||
|
<Button variant="primary" size="sm">Log In</Button>
|
||||||
|
</Link>
|
||||||
|
<Link to="/signup">
|
||||||
|
<Button variant="outline" size="sm">Sign Up</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RentalDetailPage() {
|
export function RentalDetailPage() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -74,6 +96,10 @@ export function RentalDetailPage() {
|
|||||||
: rental.cancellationPolicy === 'MODERATE' ? 'Free cancellation up to 5 days before'
|
: rental.cancellationPolicy === 'MODERATE' ? 'Free cancellation up to 5 days before'
|
||||||
: 'No refund after booking confirmation';
|
: 'No refund after booking confirmation';
|
||||||
|
|
||||||
|
const truncatedDescription = rental.description.length > 200
|
||||||
|
? rental.description.slice(0, 200) + '...'
|
||||||
|
: rental.description;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
{/* Image Gallery */}
|
{/* Image Gallery */}
|
||||||
@@ -88,7 +114,7 @@ export function RentalDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasImages && rental.images.length > 1 && (
|
{hasImages && rental.images.length > 1 && isAuthenticated && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveImage(prev => prev > 0 ? prev - 1 : rental.images.length - 1)}
|
onClick={() => setActiveImage(prev => prev > 0 ? prev - 1 : rental.images.length - 1)}
|
||||||
@@ -110,12 +136,19 @@ export function RentalDetailPage() {
|
|||||||
{rental.images.slice(0, 6).map((img, i) => (
|
{rental.images.slice(0, 6).map((img, i) => (
|
||||||
<button
|
<button
|
||||||
key={img.id}
|
key={img.id}
|
||||||
onClick={() => setActiveImage(i)}
|
onClick={() => isAuthenticated && setActiveImage(i)}
|
||||||
className={`aspect-square rounded-xl overflow-hidden cursor-pointer transition-all ${
|
className={`relative aspect-square rounded-xl overflow-hidden transition-all ${
|
||||||
i === activeImage ? 'ring-2 ring-primary-400' : 'hover:ring-2 hover:ring-gray-300'
|
!isAuthenticated && i > 0 ? 'cursor-default' : 'cursor-pointer'
|
||||||
|
} ${
|
||||||
|
i === activeImage && isAuthenticated ? 'ring-2 ring-primary-400' : 'hover:ring-2 hover:ring-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img src={img.url} alt="" className="w-full h-full object-cover" />
|
<img src={img.url} alt="" className={`w-full h-full object-cover ${!isAuthenticated && i > 0 ? 'blur-sm' : ''}`} />
|
||||||
|
{!isAuthenticated && i > 0 && (
|
||||||
|
<div className="absolute inset-0 bg-black/30 flex items-center justify-center">
|
||||||
|
<Lock className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -158,39 +191,56 @@ export function RentalDetailPage() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">Description</h3>
|
<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>
|
{isAuthenticated ? (
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{rental.description}</p>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{truncatedDescription}</p>
|
||||||
|
{rental.description.length > 200 && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-t from-white to-transparent" />
|
||||||
|
)}
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 flex items-center gap-2 text-sm text-primary-600">
|
||||||
|
<Lock className="w-4 h-4" />
|
||||||
|
<Link to="/login" className="font-medium hover:text-primary-700">Log in to read the full description</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Amenities */}
|
{/* Amenities — auth gated */}
|
||||||
{rental.amenities.length > 0 && (
|
{isAuthenticated ? (
|
||||||
<Card>
|
rental.amenities.length > 0 && (
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">Amenities</h3>
|
<Card>
|
||||||
<div className="flex flex-wrap gap-2">
|
<h3 className="font-semibold text-gray-900 mb-3">Amenities</h3>
|
||||||
{rental.amenities.map((amenity, i) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-primary-50 text-primary-700 text-sm font-medium">
|
{rental.amenities.map((amenity, i) => (
|
||||||
{amenity}
|
<span key={i} className="px-3 py-1.5 rounded-lg bg-primary-50 text-primary-700 text-sm font-medium">
|
||||||
</span>
|
{amenity}
|
||||||
))}
|
</span>
|
||||||
</div>
|
))}
|
||||||
</Card>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Rules */}
|
{/* Rules — auth gated */}
|
||||||
{rental.rules.length > 0 && (
|
{isAuthenticated ? (
|
||||||
<Card>
|
rental.rules.length > 0 && (
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">House Rules</h3>
|
<Card>
|
||||||
<ul className="space-y-2">
|
<h3 className="font-semibold text-gray-900 mb-3">House Rules</h3>
|
||||||
{rental.rules.map((rule, i) => (
|
<ul className="space-y-2">
|
||||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
{rental.rules.map((rule, i) => (
|
||||||
<span className="text-gray-400 mt-0.5">--</span>
|
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||||
{rule}
|
<span className="text-gray-400 mt-0.5">--</span>
|
||||||
</li>
|
{rule}
|
||||||
))}
|
</li>
|
||||||
</ul>
|
))}
|
||||||
</Card>
|
</ul>
|
||||||
)}
|
</Card>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Cancellation Policy */}
|
{/* Cancellation Policy — always visible */}
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="font-semibold text-gray-900 mb-2">Cancellation Policy</h3>
|
<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">
|
<Badge variant={rental.cancellationPolicy === 'FLEXIBLE' ? 'success' : rental.cancellationPolicy === 'MODERATE' ? 'warning' : 'error'} size="md">
|
||||||
@@ -199,36 +249,43 @@ export function RentalDetailPage() {
|
|||||||
<p className="text-sm text-gray-500 mt-2">{cancellationLabel}</p>
|
<p className="text-sm text-gray-500 mt-2">{cancellationLabel}</p>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Availability Calendar */}
|
{/* Auth-gated sections: calendar, reviews */}
|
||||||
<Card>
|
{isAuthenticated ? (
|
||||||
<h3 className="font-semibold text-gray-900 mb-3">Availability</h3>
|
<>
|
||||||
<AvailabilityCalendar blockedDates={blockedDates} bookedDates={bookedDates} />
|
{/* Availability Calendar */}
|
||||||
</Card>
|
<Card>
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-3">Availability</h3>
|
||||||
|
<AvailabilityCalendar blockedDates={blockedDates} bookedDates={bookedDates} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<h3 className="font-semibold text-gray-900">Reviews</h3>
|
<h3 className="font-semibold text-gray-900">Reviews</h3>
|
||||||
{rental.avgRating !== undefined && (
|
{rental.avgRating !== undefined && (
|
||||||
<span className="flex items-center gap-1 text-sm font-medium">
|
<span className="flex items-center gap-1 text-sm font-medium">
|
||||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||||
{rental.avgRating.toFixed(1)}
|
{rental.avgRating.toFixed(1)}
|
||||||
{rental._count?.reviews !== undefined && (
|
{rental._count?.reviews !== undefined && (
|
||||||
<span className="text-gray-400">({rental._count.reviews})</span>
|
<span className="text-gray-400">({rental._count.reviews})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
)}
|
{reviews.length === 0 ? (
|
||||||
</div>
|
<p className="text-sm text-gray-400">No reviews yet.</p>
|
||||||
{reviews.length === 0 ? (
|
) : (
|
||||||
<p className="text-sm text-gray-400">No reviews yet.</p>
|
<div className="space-y-4">
|
||||||
) : (
|
{reviews.map(review => (
|
||||||
<div className="space-y-4">
|
<ReviewCard key={review.id} review={review} />
|
||||||
{reviews.map(review => (
|
))}
|
||||||
<ReviewCard key={review.id} review={review} />
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</>
|
||||||
</div>
|
) : (
|
||||||
|
<LoginGate />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex gap-4 text-sm text-gray-400">
|
<div className="flex gap-4 text-sm text-gray-400">
|
||||||
@@ -246,8 +303,8 @@ export function RentalDetailPage() {
|
|||||||
|
|
||||||
{/* Right column - Booking and Landlord */}
|
{/* Right column - Booking and Landlord */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Booking form */}
|
{/* Booking form — auth gated */}
|
||||||
{!isOwner && (
|
{isAuthenticated && !isOwner && (
|
||||||
<div className="sticky top-24 space-y-6">
|
<div className="sticky top-24 space-y-6">
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
<BookingForm rental={rental} />
|
<BookingForm rental={rental} />
|
||||||
@@ -259,28 +316,32 @@ export function RentalDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Landlord card */}
|
{/* Landlord card — auth gated */}
|
||||||
<Card>
|
{isAuthenticated ? (
|
||||||
<h3 className="text-sm font-medium text-gray-500 mb-3">Hosted by</h3>
|
<Card>
|
||||||
<div className="flex items-center gap-4">
|
<h3 className="text-sm font-medium text-gray-500 mb-3">Hosted by</h3>
|
||||||
<Avatar name={rental.landlord.fullName} src={rental.landlord.avatar} size="lg" />
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex-1">
|
<Avatar name={rental.landlord.fullName} src={rental.landlord.avatar} size="lg" />
|
||||||
<h3 className="font-semibold text-gray-900">{rental.landlord.fullName}</h3>
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<h3 className="font-semibold text-gray-900">{rental.landlord.fullName}</h3>
|
||||||
{rental.landlord.rating !== undefined && (
|
<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>
|
<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>
|
||||||
)}
|
)}
|
||||||
<span className="text-xs text-gray-400">Joined {formatDate(rental.landlord.createdAt)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{rental.landlord.landlordVerified && (
|
|
||||||
<span className="mt-1"><Badge variant="success" size="sm">Verified</Badge></span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
) : (
|
||||||
|
!isOwner && <LoginGate />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import type { RentalListing } from '../types/rental';
|
|||||||
import type { PaginatedResponse } from '../types';
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'popular';
|
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'popular';
|
||||||
|
type PeriodFilter = 'ALL' | 'DAILY' | 'MONTHLY';
|
||||||
|
|
||||||
export function RentalsPage() {
|
export function RentalsPage() {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||||
const [sort, setSort] = useState<SortOption>('newest');
|
const [sort, setSort] = useState<SortOption>('newest');
|
||||||
|
const [periodType, setPeriodType] = useState<PeriodFilter>('ALL');
|
||||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -28,6 +30,7 @@ export function RentalsPage() {
|
|||||||
});
|
});
|
||||||
if (selectedCategory) params.set('category', selectedCategory as string);
|
if (selectedCategory) params.set('category', selectedCategory as string);
|
||||||
if (searchQuery) params.set('search', searchQuery);
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
if (periodType !== 'ALL') params.set('periodType', periodType);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PaginatedResponse<RentalListing>>(`/rentals?${params}`);
|
const res = await api.get<PaginatedResponse<RentalListing>>(`/rentals?${params}`);
|
||||||
@@ -39,7 +42,7 @@ export function RentalsPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, sort, selectedCategory, searchQuery]);
|
}, [page, sort, selectedCategory, searchQuery, periodType]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRentals();
|
fetchRentals();
|
||||||
@@ -102,6 +105,23 @@ export function RentalsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Period type filter */}
|
||||||
|
<div className="flex gap-1 mb-6 bg-gray-100 rounded-xl p-1 w-fit">
|
||||||
|
{([['ALL', 'All'], ['DAILY', 'Daily'], ['MONTHLY', 'Monthly']] as const).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
onClick={() => { setPeriodType(value); setPage(1); }}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors cursor-pointer ${
|
||||||
|
periodType === value
|
||||||
|
? 'bg-white text-primary-700 shadow-sm'
|
||||||
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-gray-500 text-center py-12">Loading rentals...</p>
|
<p className="text-gray-500 text-center py-12">Loading rentals...</p>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Input } from '../components/ui/Input';
|
|||||||
import { GradientButton } from '../components/ui/GradientButton';
|
import { GradientButton } from '../components/ui/GradientButton';
|
||||||
import { Button } from '../components/ui/Button';
|
import { Button } from '../components/ui/Button';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { ApiError } from '../api/client';
|
||||||
|
|
||||||
export function SignUpPage() {
|
export function SignUpPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,21 +16,53 @@ export function SignUpPage() {
|
|||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
if (fullName.trim().length < 2) {
|
||||||
|
errors.fullName = 'Name must be at least 2 characters';
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
errors.password = 'Password must be at least 8 characters';
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
errors.confirmPassword = 'Passwords do not match';
|
||||||
|
}
|
||||||
|
setFieldErrors(errors);
|
||||||
|
return Object.keys(errors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (password !== confirmPassword) {
|
|
||||||
setError('Passwords do not match');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError('');
|
setError('');
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
await signup({ fullName, email, password });
|
await signup({ fullName: fullName.trim(), email, password });
|
||||||
navigate('/profile/create');
|
navigate('/profile/create');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Sign up failed');
|
if (err instanceof ApiError) {
|
||||||
|
if (err.status === 409) {
|
||||||
|
setError('This email is already registered. Try logging in.');
|
||||||
|
} else if (err.status === 400 && err.data?.errors?.length) {
|
||||||
|
const firstError = err.data.errors[0];
|
||||||
|
const field = firstError.path?.[0];
|
||||||
|
if (field) {
|
||||||
|
setFieldErrors({ [field]: firstError.message });
|
||||||
|
} else {
|
||||||
|
setError(firstError.message || 'Please check your input.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(err.message || 'Registration failed. Please try again.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError('Registration failed. Please try again.');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -67,20 +100,31 @@ export function SignUpPage() {
|
|||||||
{error && <div className="mb-4 p-3 rounded-xl bg-red-50 text-red-600 text-sm">{error}</div>}
|
{error && <div className="mb-4 p-3 rounded-xl bg-red-50 text-red-600 text-sm">{error}</div>}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<Input label="Full Name" placeholder="Enter your full name" value={fullName} onChange={(e) => setFullName(e.target.value)}
|
<div>
|
||||||
icon={<User className="w-4 h-4" />} required />
|
<Input label="Full Name" placeholder="Enter your full name" value={fullName} onChange={(e) => setFullName(e.target.value)}
|
||||||
|
icon={<User className="w-4 h-4" />} required />
|
||||||
|
{fieldErrors.fullName && <p className="text-red-500 text-xs mt-1">{fieldErrors.fullName}</p>}
|
||||||
|
</div>
|
||||||
<Input label="Email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
|
<Input label="Email" type="email" placeholder="Enter your email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
icon={<Mail className="w-4 h-4" />} required />
|
icon={<Mail className="w-4 h-4" />} required />
|
||||||
<div className="relative">
|
<div>
|
||||||
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Create a password" value={password} onChange={(e) => setPassword(e.target.value)}
|
<div className="relative">
|
||||||
icon={<Lock className="w-4 h-4" />} required />
|
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Create a password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
icon={<Lock className="w-4 h-4" />} required />
|
||||||
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||||
</button>
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-1 ${fieldErrors.password ? 'text-red-500' : 'text-gray-400'}`}>
|
||||||
|
{fieldErrors.password || 'Minimum 8 characters'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input label="Confirm Password" type={showPassword ? 'text' : 'password'} placeholder="Confirm your password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
icon={<Lock className="w-4 h-4" />} required />
|
||||||
|
{fieldErrors.confirmPassword && <p className="text-red-500 text-xs mt-1">{fieldErrors.confirmPassword}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Input label="Confirm Password" type={showPassword ? 'text' : 'password'} placeholder="Confirm your password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
icon={<Lock className="w-4 h-4" />} required />
|
|
||||||
<label className="flex items-start gap-2 text-xs text-gray-500">
|
<label className="flex items-start gap-2 text-xs text-gray-500">
|
||||||
<input type="checkbox" required className="mt-0.5 accent-primary-600" />
|
<input type="checkbox" required className="mt-0.5 accent-primary-600" />
|
||||||
By signing up, you agree to our <Link to="/terms" className="text-primary-600 underline">Terms of Service</Link> and <Link to="/privacy" className="text-primary-600 underline">Privacy Policy</Link>
|
By signing up, you agree to our <Link to="/terms" className="text-primary-600 underline">Terms of Service</Link> and <Link to="/privacy" className="text-primary-600 underline">Privacy Policy</Link>
|
||||||
|
|||||||
92
client/src/pages/TenantDashboardPage.tsx
Normal file
92
client/src/pages/TenantDashboardPage.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Calendar, DollarSign, Star, Clock } from 'lucide-react';
|
||||||
|
import { StatCard } from '../components/ui/StatCard';
|
||||||
|
import { UpcomingStays } from '../components/rentals/UpcomingStays';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { formatCurrency } from '../utils/format';
|
||||||
|
import type { Booking } from '../types/rental';
|
||||||
|
|
||||||
|
export function TenantDashboardPage() {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const res = await api.get<Booking[]>('/bookings?role=tenant');
|
||||||
|
setBookings(res);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center text-gray-400 py-12">Loading dashboard...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBookings = bookings.length;
|
||||||
|
const upcomingCount = bookings.filter(b => b.status === 'CONFIRMED' || b.status === 'ACTIVE').length;
|
||||||
|
const totalSpent = bookings
|
||||||
|
.filter(b => ['COMPLETED', 'CONFIRMED', 'ACTIVE'].includes(b.status))
|
||||||
|
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||||
|
const completedWithReview = bookings.filter(b => b.status === 'COMPLETED' && b.review);
|
||||||
|
const avgRating = completedWithReview.length > 0
|
||||||
|
? completedWithReview.reduce((sum, b) => sum + (b.review?.rating || 0), 0) / completedWithReview.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
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-4 gap-4 mb-8">
|
||||||
|
<StatCard icon={Calendar} label="Total Bookings" value={totalBookings} color="blue" />
|
||||||
|
<StatCard icon={Clock} label="Upcoming" value={upcomingCount} color="green" />
|
||||||
|
<StatCard icon={DollarSign} label="Total Spent" value={formatCurrency(totalSpent)} color="pink" />
|
||||||
|
<StatCard icon={Star} label="Avg Rating Given" value={avgRating ? avgRating.toFixed(1) : 'N/A'} color="yellow" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UpcomingStays bookings={bookings} />
|
||||||
|
|
||||||
|
{/* Recent Completed */}
|
||||||
|
{bookings.filter(b => b.status === 'COMPLETED').length > 0 && (
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Recent Completed Stays</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{bookings
|
||||||
|
.filter(b => b.status === 'COMPLETED')
|
||||||
|
.slice(0, 5)
|
||||||
|
.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">
|
||||||
|
{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">{formatCurrency(booking.totalAmount)}</span>
|
||||||
|
{booking.review && (
|
||||||
|
<span className="text-xs text-yellow-500">{booking.review.rating} ★</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { ShoppingBag, Calendar, DollarSign, Star } from 'lucide-react';
|
import { ShoppingBag, Calendar, DollarSign, Star, Pause, Play } from 'lucide-react';
|
||||||
import { StatCard } from '../../components/ui/StatCard';
|
import { StatCard } from '../../components/ui/StatCard';
|
||||||
import { Badge } from '../../components/ui/Badge';
|
import { Badge } from '../../components/ui/Badge';
|
||||||
|
import { EarningsChart } from '../../components/charts/EarningsChart';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { RentalListing, Booking } from '../../types/rental';
|
import type { RentalListing, Booking } from '../../types/rental';
|
||||||
|
|
||||||
@@ -14,39 +15,56 @@ interface LandlordStats {
|
|||||||
|
|
||||||
export function LandlordDashboardPage() {
|
export function LandlordDashboardPage() {
|
||||||
const [stats, setStats] = useState<LandlordStats | null>(null);
|
const [stats, setStats] = useState<LandlordStats | null>(null);
|
||||||
|
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchData = async () => {
|
||||||
async function fetchData() {
|
try {
|
||||||
try {
|
const [rentalsRes, bookingsRes] = await Promise.all([
|
||||||
const [rentals, bookings] = await Promise.all([
|
api.get<RentalListing[]>('/rentals/mine'),
|
||||||
api.get<RentalListing[]>('/rentals/mine'),
|
api.get<Booking[]>('/bookings?role=landlord'),
|
||||||
api.get<Booking[]>('/bookings?role=landlord'),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
const totalRentals = rentals.length;
|
setRentals(rentalsRes);
|
||||||
const activeBookings = bookings.filter(
|
setBookings(bookingsRes);
|
||||||
(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 totalRentals = rentalsRes.length;
|
||||||
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
const activeBookings = bookingsRes.filter(
|
||||||
|
(b) => b.status === 'CONFIRMED' || b.status === 'ACTIVE'
|
||||||
|
).length;
|
||||||
|
const revenue = bookingsRes
|
||||||
|
.filter((b) => b.status === 'COMPLETED')
|
||||||
|
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||||
|
|
||||||
setStats({ totalRentals, activeBookings, revenue, avgRating });
|
const ratings = rentalsRes.filter((r) => r.avgRating).map((r) => r.avgRating!);
|
||||||
setRecentBookings(bookings.slice(0, 5));
|
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||||
} catch {
|
|
||||||
// silently fail
|
setStats({ totalRentals, activeBookings, revenue, avgRating });
|
||||||
} finally {
|
setRecentBookings(bookingsRes.slice(0, 5));
|
||||||
setLoading(false);
|
} catch {
|
||||||
}
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleStatus = async (rental: RentalListing) => {
|
||||||
|
try {
|
||||||
|
if (rental.status === 'ACTIVE') {
|
||||||
|
await api.post(`/rentals/${rental.id}/pause`);
|
||||||
|
} else {
|
||||||
|
await api.post(`/rentals/${rental.id}/activate`);
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center text-gray-400 py-12">Loading dashboard...</div>;
|
return <div className="text-center text-gray-400 py-12">Loading dashboard...</div>;
|
||||||
}
|
}
|
||||||
@@ -65,6 +83,21 @@ export function LandlordDashboardPage() {
|
|||||||
return <Badge variant={map[status] || 'default'} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
return <Badge variant={map[status] || 'default'} size="sm">{status.replace(/_/g, ' ')}</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Calculate per-property stats
|
||||||
|
const propertyStats = rentals
|
||||||
|
.filter(r => r.status !== 'DELETED')
|
||||||
|
.map(rental => {
|
||||||
|
const rentalBookings = bookings.filter(b => b.rentalListingId === rental.id);
|
||||||
|
const rentalRevenue = rentalBookings
|
||||||
|
.filter(b => b.status === 'COMPLETED')
|
||||||
|
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||||
|
return {
|
||||||
|
...rental,
|
||||||
|
bookingCount: rentalBookings.length,
|
||||||
|
revenue: rentalRevenue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Landlord Dashboard</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Landlord Dashboard</h1>
|
||||||
@@ -76,6 +109,56 @@ export function LandlordDashboardPage() {
|
|||||||
<StatCard icon={Star} label="Avg Rating" value={stats?.avgRating ? stats.avgRating.toFixed(1) : 'N/A'} color="yellow" />
|
<StatCard icon={Star} label="Avg Rating" value={stats?.avgRating ? stats.avgRating.toFixed(1) : 'N/A'} color="yellow" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Earnings Chart */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<EarningsChart bookings={bookings} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property Performance */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5 mb-8">
|
||||||
|
<h3 className="font-semibold text-gray-900 mb-4">Property Performance</h3>
|
||||||
|
{propertyStats.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 py-4 text-center">No properties yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
{propertyStats.map((property) => (
|
||||||
|
<div key={property.id} className="flex items-center gap-3 p-3 rounded-xl bg-gray-50">
|
||||||
|
<div className="w-12 h-12 rounded-lg bg-gray-200 overflow-hidden flex-shrink-0">
|
||||||
|
{property.images[0] ? (
|
||||||
|
<img src={property.images[0].url} className="w-full h-full object-cover" alt="" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full bg-gray-300" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">{property.title}</p>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
||||||
|
<span>{property.bookingCount} bookings</span>
|
||||||
|
<span>${property.revenue.toFixed(2)} earned</span>
|
||||||
|
{property.avgRating ? <span>{property.avgRating.toFixed(1)} ★</span> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<Badge variant={property.status === 'ACTIVE' ? 'success' : 'warning'} size="sm">
|
||||||
|
{property.status === 'ACTIVE' ? 'Active' : property.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
{(property.status === 'ACTIVE' || property.status === 'PAUSED' || property.status === 'DRAFT') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(property)}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-200 text-gray-500 hover:text-gray-700 cursor-pointer transition-colors"
|
||||||
|
title={property.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||||
|
>
|
||||||
|
{property.status === 'ACTIVE' ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Bookings */}
|
||||||
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-5">
|
<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>
|
<h3 className="font-semibold text-gray-900 mb-4">Recent Bookings</h3>
|
||||||
{recentBookings.length === 0 ? (
|
{recentBookings.length === 0 ? (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Pencil, Pause, Play, Trash2 } from 'lucide-react';
|
import { Pencil, Trash2, Eye } from 'lucide-react';
|
||||||
import { DataTable } from '../../components/ui/DataTable';
|
import { DataTable } from '../../components/ui/DataTable';
|
||||||
import { Badge } from '../../components/ui/Badge';
|
import { Badge } from '../../components/ui/Badge';
|
||||||
import { Button } from '../../components/ui/Button';
|
import { Button } from '../../components/ui/Button';
|
||||||
@@ -93,15 +93,29 @@ export function LandlordListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'category',
|
|
||||||
header: 'Category',
|
|
||||||
render: (l: RentalListing) => l.category.replace(/_/g, ' '),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'status',
|
key: 'status',
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
render: (l: RentalListing) => statusBadge(l.status),
|
render: (l: RentalListing) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(l.status === 'ACTIVE' || l.status === 'PAUSED' || l.status === 'DRAFT') && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(l)}
|
||||||
|
className={`relative w-10 h-5 rounded-full transition-colors cursor-pointer ${
|
||||||
|
l.status === 'ACTIVE' ? 'bg-green-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
title={l.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${
|
||||||
|
l.status === 'ACTIVE' ? 'translate-x-5' : 'translate-x-0.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{statusBadge(l.status)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'price',
|
key: 'price',
|
||||||
@@ -113,6 +127,16 @@ export function LandlordListingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'views',
|
||||||
|
header: 'Views',
|
||||||
|
render: (l: RentalListing) => (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-gray-500">
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
{l.viewCount}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'bookings',
|
key: 'bookings',
|
||||||
header: 'Bookings',
|
header: 'Bookings',
|
||||||
@@ -134,13 +158,6 @@ export function LandlordListingsPage() {
|
|||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</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
|
<button
|
||||||
onClick={() => handleDelete(l)}
|
onClick={() => handleDelete(l)}
|
||||||
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600 cursor-pointer"
|
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600 cursor-pointer"
|
||||||
|
|||||||
212
client/src/pages/landlord/LandlordSubscriptionPage.tsx
Normal file
212
client/src/pages/landlord/LandlordSubscriptionPage.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { CreditCard, Check, Crown, Building2, Zap } from 'lucide-react';
|
||||||
|
import { Button } from '../../components/ui/Button';
|
||||||
|
import { GradientButton } from '../../components/ui/GradientButton';
|
||||||
|
import { Badge } from '../../components/ui/Badge';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import type { SubscriptionCurrentResponse, TierConfig } from '../../types';
|
||||||
|
|
||||||
|
export function LandlordSubscriptionPage() {
|
||||||
|
const [current, setCurrent] = useState<SubscriptionCurrentResponse | null>(null);
|
||||||
|
const [tiers, setTiers] = useState<TierConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const [currentRes, tiersRes] = await Promise.all([
|
||||||
|
api.get<SubscriptionCurrentResponse>('/subscriptions/current'),
|
||||||
|
api.get<TierConfig[]>('/subscriptions/tiers'),
|
||||||
|
]);
|
||||||
|
setCurrent(currentRes);
|
||||||
|
setTiers(tiersRes);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChangePlan = async (tier: string) => {
|
||||||
|
if (tier === 'BASIC') {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
await api.post('/subscriptions/cancel', {});
|
||||||
|
const res = await api.get<SubscriptionCurrentResponse>('/subscriptions/current');
|
||||||
|
setCurrent(res);
|
||||||
|
} catch {} finally { setUpdating(false); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
await api.post('/subscriptions/create', { tier });
|
||||||
|
const res = await api.get<SubscriptionCurrentResponse>('/subscriptions/current');
|
||||||
|
setCurrent(res);
|
||||||
|
} catch {} finally { setUpdating(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="text-center text-gray-400 py-12">Loading subscription...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentTier = current?.subscription.tier || 'BASIC';
|
||||||
|
|
||||||
|
const tierIcons: Record<string, typeof CreditCard> = {
|
||||||
|
BASIC: Zap,
|
||||||
|
PRO: Crown,
|
||||||
|
BUSINESS: Building2,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierColors: Record<string, string> = {
|
||||||
|
BASIC: 'border-gray-200',
|
||||||
|
PRO: 'border-violet-300 ring-2 ring-violet-100',
|
||||||
|
BUSINESS: 'border-amber-300 ring-2 ring-amber-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">Subscription</h1>
|
||||||
|
|
||||||
|
{/* Current Plan Summary */}
|
||||||
|
<div className="bg-gradient-to-r from-violet-50 to-pink-50 rounded-2xl border border-violet-200 p-6 mb-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-violet-600 font-medium mb-1">Current Plan</p>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">{current?.tierConfig.name || 'Basic'}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{current?.tierConfig.price === 0 ? 'Free' : `$${current?.tierConfig.price}/month`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">Active Listings</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{current?.usage.activeListings ?? 0}
|
||||||
|
<span className="text-base font-normal text-gray-400">
|
||||||
|
{' / '}{current?.tierConfig.maxActiveListings === null ? '\u221e' : current?.tierConfig.maxActiveListings}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{current?.subscription.currentPeriodEnd && (
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
Renews {new Date(current.subscription.currentPeriodEnd).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Usage bar */}
|
||||||
|
{current?.tierConfig.maxActiveListings !== null && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="w-full bg-white/60 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-violet-500 h-2 rounded-full transition-all"
|
||||||
|
style={{ width: `${Math.min(100, ((current?.usage.activeListings ?? 0) / (current?.tierConfig.maxActiveListings ?? 1)) * 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tier Comparison */}
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Compare Plans</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
|
||||||
|
{tiers.map((tier) => {
|
||||||
|
const Icon = tierIcons[tier.tier] || Zap;
|
||||||
|
const isCurrentTier = tier.tier === currentTier;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tier.tier}
|
||||||
|
className={`bg-white rounded-2xl border p-6 ${tierColors[tier.tier]} ${isCurrentTier ? 'shadow-md' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Icon className="w-5 h-5 text-violet-600" />
|
||||||
|
<h4 className="font-semibold text-gray-900">{tier.name}</h4>
|
||||||
|
{isCurrentTier && <Badge variant="success" size="sm">Current</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mb-1">
|
||||||
|
{tier.price === 0 ? 'Free' : `$${tier.price}`}
|
||||||
|
{tier.price > 0 && <span className="text-sm font-normal text-gray-400">/mo</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul className="mt-4 space-y-2.5 text-sm text-gray-600 mb-6">
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
{tier.maxActiveListings === null ? 'Unlimited' : `Up to ${tier.maxActiveListings}`} listings
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
{tier.commissionPercent}% commission
|
||||||
|
</li>
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
{tier.promoDiscount === 0 ? 'No promo discount' : tier.promoDiscount === 100 ? 'Free promotions' : `${tier.promoDiscount}% promo discount`}
|
||||||
|
</li>
|
||||||
|
{tier.badge && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
{tier.badge === 'priority' ? 'Priority badge' : 'Verified badge'}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{tier.analytics && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
|
||||||
|
Advanced analytics
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{isCurrentTier ? (
|
||||||
|
<Button variant="secondary" className="w-full" disabled>
|
||||||
|
Current Plan
|
||||||
|
</Button>
|
||||||
|
) : tier.tier === 'BASIC' ? (
|
||||||
|
<Button variant="secondary" className="w-full" onClick={() => handleChangePlan('BASIC')} disabled={updating}>
|
||||||
|
Downgrade
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<GradientButton className="w-full" onClick={() => handleChangePlan(tier.tier)} disabled={updating}>
|
||||||
|
{updating ? 'Updating...' : currentTier === 'BUSINESS' ? 'Switch' : 'Upgrade'}
|
||||||
|
</GradientButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Feature Comparison Table */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100">
|
||||||
|
<th className="text-left px-6 py-3 text-gray-500 font-medium">Feature</th>
|
||||||
|
{tiers.map(t => (
|
||||||
|
<th key={t.tier} className="text-center px-4 py-3 text-gray-900 font-semibold">{t.name}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{[
|
||||||
|
{ label: 'Monthly Price', values: tiers.map(t => t.price === 0 ? 'Free' : `$${t.price}`) },
|
||||||
|
{ label: 'Active Listings', values: tiers.map(t => t.maxActiveListings === null ? 'Unlimited' : String(t.maxActiveListings)) },
|
||||||
|
{ label: 'Commission Rate', values: tiers.map(t => `${t.commissionPercent}%`) },
|
||||||
|
{ label: 'Promo Discount', values: tiers.map(t => t.promoDiscount === 0 ? '-' : t.promoDiscount === 100 ? 'Free' : `${t.promoDiscount}%`) },
|
||||||
|
{ label: 'Badge', values: tiers.map(t => t.badge ? (t.badge === 'priority' ? 'Priority' : 'Verified') : '-') },
|
||||||
|
{ label: 'Analytics', values: tiers.map(t => t.analytics ? 'Yes' : '-') },
|
||||||
|
].map(row => (
|
||||||
|
<tr key={row.label} className="border-b border-gray-50">
|
||||||
|
<td className="px-6 py-3 text-gray-600">{row.label}</td>
|
||||||
|
{row.values.map((v, i) => (
|
||||||
|
<td key={i} className="text-center px-4 py-3 text-gray-900">{v}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ import { LandlordBookingsPage } from './pages/landlord/LandlordBookingsPage';
|
|||||||
import { LandlordCalendarPage } from './pages/landlord/LandlordCalendarPage';
|
import { LandlordCalendarPage } from './pages/landlord/LandlordCalendarPage';
|
||||||
import { LandlordPayoutsPage } from './pages/landlord/LandlordPayoutsPage';
|
import { LandlordPayoutsPage } from './pages/landlord/LandlordPayoutsPage';
|
||||||
import { LandlordReviewsPage } from './pages/landlord/LandlordReviewsPage';
|
import { LandlordReviewsPage } from './pages/landlord/LandlordReviewsPage';
|
||||||
|
import { LandlordSubscriptionPage } from './pages/landlord/LandlordSubscriptionPage';
|
||||||
|
import { TenantDashboardPage } from './pages/TenantDashboardPage';
|
||||||
import { AdminRentalsPage } from './pages/admin/AdminRentalsPage';
|
import { AdminRentalsPage } from './pages/admin/AdminRentalsPage';
|
||||||
import { AdminBookingsPage } from './pages/admin/AdminBookingsPage';
|
import { AdminBookingsPage } from './pages/admin/AdminBookingsPage';
|
||||||
import { AdminRentalPayoutsPage } from './pages/admin/AdminRentalPayoutsPage';
|
import { AdminRentalPayoutsPage } from './pages/admin/AdminRentalPayoutsPage';
|
||||||
@@ -88,6 +90,7 @@ export const router = createBrowserRouter([
|
|||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
element: <RequireAuth><DashboardLayout /></RequireAuth>,
|
element: <RequireAuth><DashboardLayout /></RequireAuth>,
|
||||||
children: [
|
children: [
|
||||||
|
{ index: true, element: <TenantDashboardPage /> },
|
||||||
{ path: 'messages', element: <ChatPage /> },
|
{ path: 'messages', element: <ChatPage /> },
|
||||||
{ path: 'offers', element: <MyOffersPage /> },
|
{ path: 'offers', element: <MyOffersPage /> },
|
||||||
{ path: 'notifications', element: <NotificationsPage /> },
|
{ path: 'notifications', element: <NotificationsPage /> },
|
||||||
@@ -109,6 +112,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'calendar', element: <LandlordCalendarPage /> },
|
{ path: 'calendar', element: <LandlordCalendarPage /> },
|
||||||
{ path: 'payouts', element: <LandlordPayoutsPage /> },
|
{ path: 'payouts', element: <LandlordPayoutsPage /> },
|
||||||
{ path: 'reviews', element: <LandlordReviewsPage /> },
|
{ path: 'reviews', element: <LandlordReviewsPage /> },
|
||||||
|
{ path: 'subscription', element: <LandlordSubscriptionPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -174,6 +174,35 @@ export interface ModerationLog {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS';
|
||||||
|
|
||||||
|
export interface TierConfig {
|
||||||
|
name: string;
|
||||||
|
tier: SubscriptionTier;
|
||||||
|
price: number;
|
||||||
|
maxActiveListings: number | null;
|
||||||
|
commissionPercent: number;
|
||||||
|
promoDiscount: number;
|
||||||
|
badge: string | null;
|
||||||
|
analytics: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Subscription {
|
||||||
|
id?: string;
|
||||||
|
userId: string;
|
||||||
|
tier: SubscriptionTier;
|
||||||
|
status: 'ACTIVE' | 'CANCELLED' | 'EXPIRED';
|
||||||
|
currentPeriodEnd?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionCurrentResponse {
|
||||||
|
subscription: Subscription;
|
||||||
|
tierConfig: TierConfig;
|
||||||
|
usage: { activeListings: number };
|
||||||
|
}
|
||||||
|
|
||||||
export interface AdminStats {
|
export interface AdminStats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalListings: number;
|
totalListings: number;
|
||||||
|
|||||||
56
docs/business-model.md
Normal file
56
docs/business-model.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Revenue Model
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The platform generates revenue through 5 distinct streams, creating a diversified monetization strategy.
|
||||||
|
|
||||||
|
## Revenue Streams
|
||||||
|
|
||||||
|
### 1. Listing Fee (Marketplace)
|
||||||
|
- **Amount:** $5 per marketplace listing
|
||||||
|
- **Who pays:** Seller
|
||||||
|
- **When:** At listing creation
|
||||||
|
|
||||||
|
### 2. Marketplace Commission
|
||||||
|
- **Rate:** 5% of sale price
|
||||||
|
- **Who pays:** Seller (deducted from payout)
|
||||||
|
- **When:** Upon successful sale completion
|
||||||
|
|
||||||
|
### 3. Rental Commission
|
||||||
|
- **Rate:** 5-10% of booking subtotal (varies by landlord subscription tier)
|
||||||
|
- **Who pays:** Landlord (deducted from payout)
|
||||||
|
- **When:** Upon booking completion
|
||||||
|
|
||||||
|
| Tier | Commission Rate |
|
||||||
|
|------|----------------|
|
||||||
|
| BASIC | 10% |
|
||||||
|
| PRO | 7% |
|
||||||
|
| BUSINESS | 5% |
|
||||||
|
|
||||||
|
### 4. Promoted Listings
|
||||||
|
- **Rate:** $2.99 - $3.99 per day
|
||||||
|
- **Who pays:** Seller / Landlord
|
||||||
|
- **Description:** Boosted visibility in search results and category pages
|
||||||
|
|
||||||
|
### 5. Subscription Plans
|
||||||
|
|
||||||
|
| Plan | Monthly Price |
|
||||||
|
|------|--------------|
|
||||||
|
| BASIC | Free |
|
||||||
|
| PRO | $9.99/month |
|
||||||
|
| BUSINESS | $29.99/month |
|
||||||
|
|
||||||
|
- **Who pays:** Landlord
|
||||||
|
- **When:** Monthly recurring
|
||||||
|
|
||||||
|
## Revenue Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
Total Revenue = Listing Fees + Marketplace Commission + Rental Commission + Promotions + Subscriptions
|
||||||
|
|
||||||
|
Monthly Revenue = (N_listings × $5)
|
||||||
|
+ (GMV_marketplace × 0.05)
|
||||||
|
+ (GMV_rental × avg_commission_rate)
|
||||||
|
+ (N_promos × avg_promo_price × avg_days)
|
||||||
|
+ (N_pro × $9.99 + N_business × $29.99)
|
||||||
|
```
|
||||||
64
docs/commission-structure.md
Normal file
64
docs/commission-structure.md
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Commission Structure
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The platform charges two types of commissions: marketplace commissions on product sales and rental commissions on property bookings.
|
||||||
|
|
||||||
|
## Marketplace Commission
|
||||||
|
|
||||||
|
- **Rate:** 5% of sale price (fixed, configured in PlatformConfig)
|
||||||
|
- **Payer:** Seller
|
||||||
|
- **Deduction:** Automatically deducted from seller payout
|
||||||
|
- **Formula:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Commission = Sale Price × 0.05
|
||||||
|
Seller Payout = Sale Price - Commission
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:** Product sold for $100
|
||||||
|
- Commission: $100 × 5% = $5.00
|
||||||
|
- Seller receives: $95.00
|
||||||
|
|
||||||
|
## Rental Commission
|
||||||
|
|
||||||
|
- **Rate:** Variable, 5-10% based on landlord's subscription tier
|
||||||
|
- **Payer:** Landlord
|
||||||
|
- **Deduction:** Automatically deducted from landlord payout on booking completion
|
||||||
|
|
||||||
|
| Tier | Commission Rate | Effective Savings vs BASIC |
|
||||||
|
|------|----------------|--------------------------|
|
||||||
|
| BASIC | 10% | - |
|
||||||
|
| PRO | 7% | 30% less commission |
|
||||||
|
| BUSINESS | 5% | 50% less commission |
|
||||||
|
|
||||||
|
### Formula
|
||||||
|
|
||||||
|
```
|
||||||
|
Commission Amount = Booking Subtotal × (Tier Commission Rate / 100)
|
||||||
|
Landlord Payout = Booking Subtotal - Commission Amount
|
||||||
|
Total Charged to Tenant = Booking Subtotal + Deposit
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calculation Flow
|
||||||
|
|
||||||
|
1. Tenant creates booking request
|
||||||
|
2. System looks up landlord's subscription tier
|
||||||
|
3. Commission rate is determined from TIER_CONFIG
|
||||||
|
4. Commission is calculated on subtotal (price × periods)
|
||||||
|
5. On completion, payout is created: gross - commission = net
|
||||||
|
|
||||||
|
### Example: $1,000 Monthly Booking
|
||||||
|
|
||||||
|
| Component | BASIC (10%) | PRO (7%) | BUSINESS (5%) |
|
||||||
|
|-----------|-------------|----------|---------------|
|
||||||
|
| Subtotal | $1,000 | $1,000 | $1,000 |
|
||||||
|
| Commission | $100 | $70 | $50 |
|
||||||
|
| Landlord Payout | $900 | $930 | $950 |
|
||||||
|
| Platform Revenue | $100 | $70 | $50 |
|
||||||
|
|
||||||
|
## Deposit Handling
|
||||||
|
|
||||||
|
- Deposits are separate from commission calculations
|
||||||
|
- Deposits are fully returned on cancellation (regardless of refund policy)
|
||||||
|
- Deposits are not subject to commission
|
||||||
97
docs/growth-projections.md
Normal file
97
docs/growth-projections.md
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
# Growth Projection Template
|
||||||
|
|
||||||
|
## Key Formulas
|
||||||
|
|
||||||
|
### Monthly Recurring Revenue (MRR)
|
||||||
|
|
||||||
|
```
|
||||||
|
MRR = (N_pro × $9.99) + (N_business × $29.99) + Monthly Commission Revenue
|
||||||
|
|
||||||
|
Monthly Commission Revenue = Sum of all booking commissions in month
|
||||||
|
```
|
||||||
|
|
||||||
|
### Annual Recurring Revenue (ARR)
|
||||||
|
|
||||||
|
```
|
||||||
|
ARR = MRR × 12
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customer Lifetime Value (LTV)
|
||||||
|
|
||||||
|
```
|
||||||
|
LTV = ARPL × Average Customer Lifetime (months)
|
||||||
|
|
||||||
|
ARPL = (Subscription Revenue + Commission Revenue) / Total Active Landlords
|
||||||
|
|
||||||
|
Average Customer Lifetime = 1 / Monthly Churn Rate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Customer Acquisition Cost (CAC)
|
||||||
|
|
||||||
|
```
|
||||||
|
CAC = Total Marketing Spend / New Landlords Acquired
|
||||||
|
|
||||||
|
LTV:CAC Ratio target: > 3:1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Average Booking Value (ABV)
|
||||||
|
|
||||||
|
```
|
||||||
|
ABV = Total Booking GMV / Number of Bookings
|
||||||
|
```
|
||||||
|
|
||||||
|
## Projection Model
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
| Metric | Month 1 | Month 6 | Month 12 |
|
||||||
|
|--------|---------|---------|----------|
|
||||||
|
| Total Landlords | 50 | 300 | 1,000 |
|
||||||
|
| BASIC (%) | 80% | 60% | 50% |
|
||||||
|
| PRO (%) | 15% | 25% | 30% |
|
||||||
|
| BUSINESS (%) | 5% | 15% | 20% |
|
||||||
|
| Avg Bookings/Landlord/mo | 2 | 3 | 4 |
|
||||||
|
| Avg Booking Value | $300 | $350 | $400 |
|
||||||
|
| Monthly Churn | 10% | 7% | 5% |
|
||||||
|
|
||||||
|
### Revenue Projections
|
||||||
|
|
||||||
|
| Revenue Stream | Month 1 | Month 6 | Month 12 |
|
||||||
|
|---------------|---------|---------|----------|
|
||||||
|
| **Subscriptions** | | | |
|
||||||
|
| PRO (N × $9.99) | $74.93 | $749.25 | $2,997.00 |
|
||||||
|
| BUSINESS (N × $29.99) | $74.98 | $1,349.55 | $5,998.00 |
|
||||||
|
| **Rental Commissions** | | | |
|
||||||
|
| BASIC (10%) | $2,400 | $18,900 | $80,000 |
|
||||||
|
| PRO (7%) | $315 | $3,675 | $33,600 |
|
||||||
|
| BUSINESS (5%) | $75 | $1,575 | $16,000 |
|
||||||
|
| **Total Monthly Revenue** | **$2,939.91** | **$26,248.80** | **$138,595.00** |
|
||||||
|
|
||||||
|
### Growth Metrics
|
||||||
|
|
||||||
|
```
|
||||||
|
Month-over-Month Growth = (MRR_current - MRR_previous) / MRR_previous × 100
|
||||||
|
|
||||||
|
Net Revenue Retention = (MRR from existing customers at end) / (MRR from same customers at start) × 100
|
||||||
|
|
||||||
|
Gross Margin = (Revenue - COGS) / Revenue × 100
|
||||||
|
COGS = Stripe fees + Hosting + Support
|
||||||
|
Target Gross Margin: > 70%
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scenario Planning
|
||||||
|
|
||||||
|
### Conservative
|
||||||
|
- 30% MoM landlord growth
|
||||||
|
- 60% stay on BASIC
|
||||||
|
- ABV: $250
|
||||||
|
|
||||||
|
### Base
|
||||||
|
- 50% MoM landlord growth
|
||||||
|
- 50% upgrade to PRO/BUSINESS within 6 months
|
||||||
|
- ABV: $350
|
||||||
|
|
||||||
|
### Aggressive
|
||||||
|
- 80% MoM landlord growth
|
||||||
|
- 60% upgrade to PRO/BUSINESS within 3 months
|
||||||
|
- ABV: $500
|
||||||
54
docs/subscription-tiers.md
Normal file
54
docs/subscription-tiers.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Subscription Tier Comparison
|
||||||
|
|
||||||
|
## Tier Overview
|
||||||
|
|
||||||
|
| Feature | BASIC | PRO | BUSINESS |
|
||||||
|
|---------|-------|-----|----------|
|
||||||
|
| **Monthly Price** | Free | $9.99 | $29.99 |
|
||||||
|
| **Max Active Listings** | 3 | 10 | Unlimited |
|
||||||
|
| **Commission Rate** | 10% | 7% | 5% |
|
||||||
|
| **Promo Discount** | None | 25% off | Free |
|
||||||
|
| **Badge** | None | Priority | Verified |
|
||||||
|
| **Advanced Analytics** | No | No | Yes |
|
||||||
|
|
||||||
|
## Detailed Breakdown
|
||||||
|
|
||||||
|
### BASIC (Free)
|
||||||
|
- Best for: New landlords testing the platform
|
||||||
|
- Up to 3 active rental listings
|
||||||
|
- Standard 10% commission on bookings
|
||||||
|
- No promotional discounts
|
||||||
|
- No special badges
|
||||||
|
- Basic dashboard access
|
||||||
|
|
||||||
|
### PRO ($9.99/month)
|
||||||
|
- Best for: Growing landlords with multiple properties
|
||||||
|
- Up to 10 active rental listings
|
||||||
|
- Reduced 7% commission (30% savings vs BASIC)
|
||||||
|
- 25% discount on promoted listings
|
||||||
|
- Priority badge on listings
|
||||||
|
- Standard dashboard access
|
||||||
|
|
||||||
|
### BUSINESS ($29.99/month)
|
||||||
|
- Best for: Professional property managers
|
||||||
|
- Unlimited active rental listings
|
||||||
|
- Lowest 5% commission (50% savings vs BASIC)
|
||||||
|
- Free promoted listings
|
||||||
|
- Verified badge on listings
|
||||||
|
- Advanced analytics dashboard
|
||||||
|
|
||||||
|
## Break-Even Analysis
|
||||||
|
|
||||||
|
### PRO Plan Break-Even
|
||||||
|
At 7% commission instead of 10%, you save 3% per booking.
|
||||||
|
Break-even monthly booking volume: $9.99 / 0.03 = **$333/month in bookings**
|
||||||
|
|
||||||
|
### BUSINESS Plan Break-Even
|
||||||
|
At 5% commission instead of 10%, you save 5% per booking.
|
||||||
|
Break-even monthly booking volume: $29.99 / 0.05 = **$600/month in bookings**
|
||||||
|
|
||||||
|
## Subscription Management
|
||||||
|
- Plans can be changed at any time
|
||||||
|
- Downgrades take effect immediately
|
||||||
|
- Upgrades take effect immediately with a new 30-day billing period
|
||||||
|
- Cancellation reverts to BASIC tier
|
||||||
59
docs/unit-economics.md
Normal file
59
docs/unit-economics.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# Unit Economics
|
||||||
|
|
||||||
|
## Per-Booking P&L by Tier
|
||||||
|
|
||||||
|
### Scenario: $500 Daily Rental (10 days × $50/day)
|
||||||
|
|
||||||
|
| Line Item | BASIC | PRO | BUSINESS |
|
||||||
|
|-----------|-------|-----|----------|
|
||||||
|
| Booking Subtotal | $500.00 | $500.00 | $500.00 |
|
||||||
|
| Commission Rate | 10% | 7% | 5% |
|
||||||
|
| Commission Amount | $50.00 | $35.00 | $25.00 |
|
||||||
|
| **Landlord Payout** | **$450.00** | **$465.00** | **$475.00** |
|
||||||
|
| **Platform Revenue** | **$50.00** | **$35.00** | **$25.00** |
|
||||||
|
|
||||||
|
### Scenario: $1,200 Monthly Rental (2 months × $600/month)
|
||||||
|
|
||||||
|
| Line Item | BASIC | PRO | BUSINESS |
|
||||||
|
|-----------|-------|-----|----------|
|
||||||
|
| Booking Subtotal | $1,200.00 | $1,200.00 | $1,200.00 |
|
||||||
|
| Commission Rate | 10% | 7% | 5% |
|
||||||
|
| Commission Amount | $120.00 | $84.00 | $60.00 |
|
||||||
|
| **Landlord Payout** | **$1,080.00** | **$1,116.00** | **$1,140.00** |
|
||||||
|
| **Platform Revenue** | **$120.00** | **$84.00** | **$60.00** |
|
||||||
|
|
||||||
|
## Monthly Landlord P&L (Example: 5 bookings/month, avg $400)
|
||||||
|
|
||||||
|
| Line Item | BASIC | PRO | BUSINESS |
|
||||||
|
|-----------|-------|-----|----------|
|
||||||
|
| Gross Booking Revenue | $2,000 | $2,000 | $2,000 |
|
||||||
|
| Subscription Cost | $0 | -$9.99 | -$29.99 |
|
||||||
|
| Commission Paid | -$200 | -$140 | -$100 |
|
||||||
|
| **Net Revenue** | **$1,800** | **$1,850.01** | **$1,870.01** |
|
||||||
|
| **Savings vs BASIC** | - | **+$50.01** | **+$70.01** |
|
||||||
|
|
||||||
|
## Platform Revenue per Landlord (Monthly)
|
||||||
|
|
||||||
|
| Revenue Source | BASIC | PRO | BUSINESS |
|
||||||
|
|----------------|-------|-----|----------|
|
||||||
|
| Subscription Fee | $0 | $9.99 | $29.99 |
|
||||||
|
| Commission (5 × $400) | $200 | $140 | $100 |
|
||||||
|
| **Total Platform Revenue** | **$200** | **$149.99** | **$129.99** |
|
||||||
|
|
||||||
|
> Note: While BASIC generates more per-landlord revenue, PRO/BUSINESS tiers drive retention and higher booking volumes, resulting in higher lifetime value.
|
||||||
|
|
||||||
|
## Key Metrics
|
||||||
|
|
||||||
|
```
|
||||||
|
Average Revenue Per Landlord (ARPL) = Subscription + Commission
|
||||||
|
Gross Margin = (Revenue - Payment Processing) / Revenue
|
||||||
|
Payment Processing ≈ 2.9% + $0.30 per transaction (Stripe)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Net Revenue per $1,000 Booking After Stripe Fees
|
||||||
|
|
||||||
|
| Tier | Commission | Stripe Fee | Net Platform Revenue |
|
||||||
|
|------|-----------|------------|---------------------|
|
||||||
|
| BASIC | $100 | ~$3.20 | $96.80 |
|
||||||
|
| PRO | $70 | ~$2.33 | $67.67 |
|
||||||
|
| BUSINESS | $50 | ~$1.75 | $48.25 |
|
||||||
@@ -832,8 +832,8 @@ async function main() {
|
|||||||
const rentalListingsData = [
|
const rentalListingsData = [
|
||||||
{
|
{
|
||||||
id: 'rental-01',
|
id: 'rental-01',
|
||||||
title: 'Modern Downtown Apartment — 2BR',
|
title: 'Sun-Drenched Loop Apartment — 2BR / River Views',
|
||||||
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.',
|
description: 'Wake up to panoramic views of the Chicago River from the 15th floor of a modern high-rise in the Loop. This fully furnished two-bedroom features floor-to-ceiling windows, a chef-ready kitchen with quartz countertops and stainless steel appliances, an in-unit washer/dryer, and a dedicated workspace perfect for remote work.\n\nThe building offers 24-hour concierge, a rooftop fitness center, heated parking, and direct access to the Riverwalk. Step outside to Millennium Park, the Art Institute, and dozens of restaurants within a five-minute walk. The Blue and Red CTA lines are one block away.\n\nLinens, towels, cookware, and high-speed fiber WiFi (500 Mbps) are all included — just bring your suitcase.',
|
||||||
category: 'APARTMENT' as const,
|
category: 'APARTMENT' as const,
|
||||||
location: 'Chicago, IL',
|
location: 'Chicago, IL',
|
||||||
dailyPrice: 120.00,
|
dailyPrice: 120.00,
|
||||||
@@ -853,8 +853,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-02',
|
id: 'rental-02',
|
||||||
title: 'Cozy Lake House with Private Dock',
|
title: 'Lakefront Retreat — Private Dock & Hot Tub',
|
||||||
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.',
|
description: 'Escape to this charming three-bedroom lake house on the north shore of Lake Geneva. Nestled among mature oaks, the property features a wraparound deck, a private dock with a boat lift, and a six-person hot tub overlooking the water.\n\nInside you will find an open-concept living room with a stone fireplace, a fully equipped kitchen, and three bedrooms that comfortably sleep eight. We provide two kayaks, stand-up paddleboards, fishing rods, and life jackets for all ages.\n\nThe property backs up to a nature preserve with 4+ miles of hiking trails. Downtown Lake Geneva — with its shops, restaurants, and the famous Shore Path — is a ten-minute drive. Ideal for family vacations, friend getaways, or a quiet creative retreat.',
|
||||||
category: 'HOUSE' as const,
|
category: 'HOUSE' as const,
|
||||||
location: 'Lake Geneva, WI',
|
location: 'Lake Geneva, WI',
|
||||||
dailyPrice: 250.00,
|
dailyPrice: 250.00,
|
||||||
@@ -871,8 +871,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-03',
|
id: 'rental-03',
|
||||||
title: 'Tesla Model 3 — Daily/Monthly Rental',
|
title: '2024 Tesla Model 3 Long Range — Autopilot Included',
|
||||||
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.',
|
description: 'Hit the road in a pristine 2024 Tesla Model 3 Long Range finished in Pearl White. This car has just 12,000 miles, a spotless interior, and every software update installed.\n\nAutopilot comes standard, and a full charge delivers 350+ miles of range — enough for a Seattle-to-Portland round trip with room to spare. The premium audio system, heated seats, and glass roof make every drive feel special.\n\nInsurance is included in the daily rate. Pickup and drop-off happen at a secure garage in Capitol Hill, or I can deliver within Seattle for a small fee. Supercharger network access included. You just need to be 25+ with a clean driving record.',
|
||||||
category: 'CAR' as const,
|
category: 'CAR' as const,
|
||||||
location: 'Seattle, WA',
|
location: 'Seattle, WA',
|
||||||
dailyPrice: 85.00,
|
dailyPrice: 85.00,
|
||||||
@@ -890,8 +890,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-04',
|
id: 'rental-04',
|
||||||
title: 'Harley-Davidson Sportster 883',
|
title: 'Harley-Davidson Sportster 883 — Weekend Warrior Ready',
|
||||||
description: 'Classic Harley-Davidson Sportster 883 in Vivid Black. Perfect for weekend rides or road trips. Helmet included. Motorcycle license required.',
|
description: 'There is nothing like cruising the Pacific Northwest on a classic Harley. This Sportster 883 Iron in Vivid Black is tuned, serviced, and ready for your next adventure — whether that is a day trip down the Columbia River Gorge or a weekend ride along the Oregon Coast.\n\nDOT-approved helmet, saddlebags, and a phone mount are included. I will walk you through the bike at pickup, and I am always a text away if you have questions on the road. Motorcycle endorsement on your license is required.',
|
||||||
category: 'MOTORCYCLE' as const,
|
category: 'MOTORCYCLE' as const,
|
||||||
location: 'Portland, OR',
|
location: 'Portland, OR',
|
||||||
dailyPrice: 65.00,
|
dailyPrice: 65.00,
|
||||||
@@ -907,8 +907,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-05',
|
id: 'rental-05',
|
||||||
title: 'Trek City Bicycle — Daily Rental',
|
title: 'Trek FX City Bike — Explore Portland on Two Wheels',
|
||||||
description: 'Comfortable Trek city bicycle perfect for exploring Portland. Includes lock, lights, and basket. Helmet available on request.',
|
description: 'The easiest way to see Portland is by bike, and this Trek FX is the perfect ride for it. Lightweight aluminum frame, 21 speeds, comfortable upright geometry, and puncture-resistant tires so you can cruise from the Pearl District to Hawthorne without a worry.\n\nEvery rental includes a U-lock, front and rear lights, a handlebar basket, and a city cycling map with curated routes. Helmet available on request at no extra charge. Pickup and drop-off at my home in the Alberta Arts District.',
|
||||||
category: 'BICYCLE' as const,
|
category: 'BICYCLE' as const,
|
||||||
location: 'Portland, OR',
|
location: 'Portland, OR',
|
||||||
dailyPrice: 15.00,
|
dailyPrice: 15.00,
|
||||||
@@ -923,8 +923,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-06',
|
id: 'rental-06',
|
||||||
title: 'VanMoof S5 Electric Bike',
|
title: 'VanMoof S5 E-Bike — Commute or Explore SF in Style',
|
||||||
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.',
|
description: 'Skip the traffic and see San Francisco the fun way on a VanMoof S5 electric bike. The integrated boost button launches you up Market Street hills effortlessly, and the 90-mile range means you can ride all day without worrying about battery.\n\nBuilt-in GPS anti-theft tracking, automatic electronic shifting, and integrated front/rear lights make this one of the smartest bikes on the road. Perfect for daily commuting, weekend rides across the Golden Gate Bridge, or exploring the city by the bay.\n\nPickup at my place in the Mission. I will adjust the seat and give you a quick demo before you head out.',
|
||||||
category: 'EBIKE' as const,
|
category: 'EBIKE' as const,
|
||||||
location: 'San Francisco, CA',
|
location: 'San Francisco, CA',
|
||||||
dailyPrice: 35.00,
|
dailyPrice: 35.00,
|
||||||
@@ -941,8 +941,8 @@ async function main() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'rental-07',
|
id: 'rental-07',
|
||||||
title: 'Luxury Penthouse — Pending Review',
|
title: 'Hollywood Hills Penthouse — Skyline & Ocean Views',
|
||||||
description: 'Stunning penthouse apartment with panoramic views. 3 bedrooms, chef kitchen, private terrace. Under review.',
|
description: 'Breathtaking three-bedroom penthouse perched above the Sunset Strip with unobstructed views from downtown LA to the Pacific. The open-plan living space features a chef kitchen with Miele appliances, floor-to-ceiling glass, and a private wraparound terrace ideal for sunset entertaining.\n\nBuilding amenities include an infinity pool, a state-of-the-art gym, 24-hour concierge, valet parking, and secure elevator access. Currently under platform review — listing will go live once verified.',
|
||||||
category: 'APARTMENT' as const,
|
category: 'APARTMENT' as const,
|
||||||
location: 'Los Angeles, CA',
|
location: 'Los Angeles, CA',
|
||||||
dailyPrice: 450.00,
|
dailyPrice: 450.00,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createBookingSchema, rejectBookingSchema, cancelBookingSchema } from '.
|
|||||||
import { AppError } from '../middleware/errorHandler.js';
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js';
|
import { checkAvailability, calculateCancellationRefund, autoTransitionBooking } from '../utils/rental.js';
|
||||||
import { getPlatformConfig } from '../utils/moderation.js';
|
import { getPlatformConfig } from '../utils/moderation.js';
|
||||||
|
import { getLandlordTier, TIER_CONFIG } from '../utils/subscription.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -111,8 +112,9 @@ router.post('/', authenticate, validate(createBookingSchema), async (req, res, n
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config = await getPlatformConfig();
|
const config = await getPlatformConfig();
|
||||||
|
const landlordTier = await getLandlordTier(rental.landlordId);
|
||||||
const subtotal = pricePerPeriod * totalPeriods;
|
const subtotal = pricePerPeriod * totalPeriods;
|
||||||
const commissionRate = config.rentalCommissionPercent;
|
const commissionRate = TIER_CONFIG[landlordTier].commissionPercent;
|
||||||
const commissionAmount = subtotal * (commissionRate / 100);
|
const commissionAmount = subtotal * (commissionRate / 100);
|
||||||
const depositAmount = rental.depositAmount || 0;
|
const depositAmount = rental.depositAmount || 0;
|
||||||
const totalAmount = subtotal + depositAmount;
|
const totalAmount = subtotal + depositAmount;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createRentalSchema, updateRentalSchema } from '../validators/rental.js'
|
|||||||
import { AppError } from '../middleware/errorHandler.js';
|
import { AppError } from '../middleware/errorHandler.js';
|
||||||
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
|
import { getPlatformConfig, checkBlockedKeywords } from '../utils/moderation.js';
|
||||||
import { checkAvailability } from '../utils/rental.js';
|
import { checkAvailability } from '../utils/rental.js';
|
||||||
|
import { checkListingLimit, TIER_CONFIG } from '../utils/subscription.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -92,15 +93,22 @@ router.get('/', optionalAuth, async (req, res, next) => {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Price filters
|
// Price / period type filters
|
||||||
if (periodType === 'DAILY' || priceMin || priceMax) {
|
if (periodType === 'DAILY') {
|
||||||
const priceField = periodType === 'MONTHLY' ? 'monthlyPrice' : 'dailyPrice';
|
const priceFilter: Record<string, unknown> = { not: null };
|
||||||
|
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
|
||||||
|
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
|
||||||
|
where.dailyPrice = priceFilter;
|
||||||
|
} else if (periodType === 'MONTHLY') {
|
||||||
|
const priceFilter: Record<string, unknown> = { not: null };
|
||||||
|
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
|
||||||
|
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
|
||||||
|
where.monthlyPrice = priceFilter;
|
||||||
|
} else if (priceMin || priceMax) {
|
||||||
const priceFilter: Record<string, unknown> = {};
|
const priceFilter: Record<string, unknown> = {};
|
||||||
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
|
if (priceMin) priceFilter.gte = parseFloat(priceMin as string);
|
||||||
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
|
if (priceMax) priceFilter.lte = parseFloat(priceMax as string);
|
||||||
if (Object.keys(priceFilter).length > 0) {
|
where.dailyPrice = priceFilter;
|
||||||
where[priceField] = priceFilter;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
|
const orderBy = sort === 'price_asc' ? { dailyPrice: 'asc' as const }
|
||||||
@@ -180,6 +188,12 @@ router.get('/:id', optionalAuth, async (req, res, next) => {
|
|||||||
// --- Create rental ---
|
// --- Create rental ---
|
||||||
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
|
router.post('/', authenticate, validate(createRentalSchema), async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
// Check listing limit by subscription tier
|
||||||
|
const limitCheck = await checkListingLimit(req.userId!);
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
throw new AppError(403, `Your ${limitCheck.tier} plan allows up to ${limitCheck.max} active listings. Upgrade your plan to add more.`);
|
||||||
|
}
|
||||||
|
|
||||||
const config = await getPlatformConfig();
|
const config = await getPlatformConfig();
|
||||||
const textToCheck = `${req.body.title} ${req.body.description}`;
|
const textToCheck = `${req.body.title} ${req.body.description}`;
|
||||||
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
|
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
|
||||||
@@ -240,6 +254,12 @@ router.post('/:id/activate', authenticate, async (req, res, next) => {
|
|||||||
throw new AppError(400, 'Rental cannot be activated from current status');
|
throw new AppError(400, 'Rental cannot be activated from current status');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check listing limit by subscription tier
|
||||||
|
const limitCheck = await checkListingLimit(req.userId!);
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
throw new AppError(403, `Your ${limitCheck.tier} plan allows up to ${limitCheck.max} active listings. Upgrade your plan to add more.`);
|
||||||
|
}
|
||||||
|
|
||||||
const config = await getPlatformConfig();
|
const config = await getPlatformConfig();
|
||||||
const textToCheck = `${existing.title} ${existing.description}`;
|
const textToCheck = `${existing.title} ${existing.description}`;
|
||||||
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
|
const blockedWord = checkBlockedKeywords(textToCheck, config.blockedKeywords);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { prisma } from '../config/database.js';
|
import { prisma } from '../config/database.js';
|
||||||
import { authenticate } from '../middleware/auth.js';
|
import { authenticate } from '../middleware/auth.js';
|
||||||
|
import { getLandlordTier, getActiveListingCount, TIER_CONFIG, type SubscriptionTier } from '../utils/subscription.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -11,7 +12,28 @@ router.get('/current', authenticate, async (req, res, next) => {
|
|||||||
where: { userId: req.userId! },
|
where: { userId: req.userId! },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId });
|
const tier = await getLandlordTier(req.userId!);
|
||||||
|
const tierConfig = TIER_CONFIG[tier];
|
||||||
|
const activeListings = await getActiveListingCount(req.userId!);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
subscription: subscription || { tier: 'BASIC', status: 'ACTIVE', userId: req.userId },
|
||||||
|
tierConfig,
|
||||||
|
usage: { activeListings },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/subscriptions/tiers
|
||||||
|
router.get('/tiers', async (_req, res, next) => {
|
||||||
|
try {
|
||||||
|
const tiers = Object.values(TIER_CONFIG).map(t => ({
|
||||||
|
...t,
|
||||||
|
maxActiveListings: t.maxActiveListings === Infinity ? null : t.maxActiveListings,
|
||||||
|
}));
|
||||||
|
res.json(tiers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -27,8 +49,8 @@ router.post('/create', authenticate, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
|
const existing = await prisma.subscription.findUnique({ where: { userId: req.userId! } });
|
||||||
if (existing && existing.status === 'ACTIVE' && existing.tier !== 'BASIC') {
|
if (existing && existing.status === 'ACTIVE' && existing.tier === tier) {
|
||||||
res.status(400).json({ message: 'Already have an active subscription' });
|
res.status(400).json({ message: 'Already on this plan' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +86,7 @@ router.post('/cancel', authenticate, async (req, res, next) => {
|
|||||||
|
|
||||||
const updated = await prisma.subscription.update({
|
const updated = await prisma.subscription.update({
|
||||||
where: { userId: req.userId! },
|
where: { userId: req.userId! },
|
||||||
data: { status: 'CANCELLED' },
|
data: { status: 'CANCELLED', tier: 'BASIC' },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
|
|||||||
85
server/src/utils/subscription.ts
Normal file
85
server/src/utils/subscription.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { prisma } from '../config/database.js';
|
||||||
|
|
||||||
|
export type SubscriptionTier = 'BASIC' | 'PRO' | 'BUSINESS';
|
||||||
|
|
||||||
|
export interface TierConfig {
|
||||||
|
name: string;
|
||||||
|
tier: SubscriptionTier;
|
||||||
|
price: number;
|
||||||
|
maxActiveListings: number;
|
||||||
|
commissionPercent: number;
|
||||||
|
promoDiscount: number;
|
||||||
|
badge: string | null;
|
||||||
|
analytics: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIER_CONFIG: Record<SubscriptionTier, TierConfig> = {
|
||||||
|
BASIC: {
|
||||||
|
name: 'Basic',
|
||||||
|
tier: 'BASIC',
|
||||||
|
price: 0,
|
||||||
|
maxActiveListings: 3,
|
||||||
|
commissionPercent: 10,
|
||||||
|
promoDiscount: 0,
|
||||||
|
badge: null,
|
||||||
|
analytics: false,
|
||||||
|
},
|
||||||
|
PRO: {
|
||||||
|
name: 'Pro',
|
||||||
|
tier: 'PRO',
|
||||||
|
price: 9.99,
|
||||||
|
maxActiveListings: 10,
|
||||||
|
commissionPercent: 7,
|
||||||
|
promoDiscount: 25,
|
||||||
|
badge: 'priority',
|
||||||
|
analytics: false,
|
||||||
|
},
|
||||||
|
BUSINESS: {
|
||||||
|
name: 'Business',
|
||||||
|
tier: 'BUSINESS',
|
||||||
|
price: 29.99,
|
||||||
|
maxActiveListings: Infinity,
|
||||||
|
commissionPercent: 5,
|
||||||
|
promoDiscount: 100,
|
||||||
|
badge: 'verified',
|
||||||
|
analytics: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLandlordTier(userId: string): Promise<SubscriptionTier> {
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription || subscription.status !== 'ACTIVE') {
|
||||||
|
return 'BASIC';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.currentPeriodEnd && subscription.currentPeriodEnd < new Date()) {
|
||||||
|
return 'BASIC';
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscription.tier as SubscriptionTier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveListingCount(userId: string): Promise<number> {
|
||||||
|
return prisma.rentalListing.count({
|
||||||
|
where: {
|
||||||
|
landlordId: userId,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkListingLimit(userId: string): Promise<{ allowed: boolean; current: number; max: number; tier: SubscriptionTier }> {
|
||||||
|
const tier = await getLandlordTier(userId);
|
||||||
|
const config = TIER_CONFIG[tier];
|
||||||
|
const current = await getActiveListingCount(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: current < config.maxActiveListings,
|
||||||
|
current,
|
||||||
|
max: config.maxActiveListings,
|
||||||
|
tier,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user