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';
|
||||
|
||||
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 {
|
||||
private accessToken: string | null = null;
|
||||
|
||||
@@ -25,7 +36,7 @@ class ApiClient {
|
||||
|
||||
if (!response.ok) {
|
||||
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();
|
||||
|
||||
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 { LayoutDashboard, List, Calendar, CalendarDays, DollarSign, Star } from 'lucide-react';
|
||||
import { LayoutDashboard, List, Calendar, CalendarDays, DollarSign, Star, CreditCard } from 'lucide-react';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/landlord', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||
@@ -8,6 +8,7 @@ const navItems = [
|
||||
{ to: '/landlord/calendar', icon: CalendarDays, label: 'Calendar' },
|
||||
{ to: '/landlord/payouts', icon: DollarSign, label: 'Payouts' },
|
||||
{ to: '/landlord/reviews', icon: Star, label: 'Reviews' },
|
||||
{ to: '/landlord/subscription', icon: CreditCard, label: 'Subscription' },
|
||||
];
|
||||
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { Calendar, DollarSign, Clock, Star } from 'lucide-react';
|
||||
import { DataTable } from '../components/ui/DataTable';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { GradientButton } from '../components/ui/GradientButton';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { StatCard } from '../components/ui/StatCard';
|
||||
import { BookingStatusBadge } from '../components/rentals/BookingStatusBadge';
|
||||
import { UpcomingStays } from '../components/rentals/UpcomingStays';
|
||||
import { api } from '../api/client';
|
||||
import { formatCurrency } from '../utils/format';
|
||||
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 (
|
||||
<div>
|
||||
<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 */}
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{STATUS_TABS.map((t) => (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { Heart, MapPin, Eye, Star, MessageSquare, Share2, Flag, ChevronLeft, ChevronRight, Lock } from 'lucide-react';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Badge } from '../components/ui/Badge';
|
||||
@@ -14,6 +14,28 @@ import { useAuth } from '../context/AuthContext';
|
||||
import { formatDate } from '../utils/format';
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -74,6 +96,10 @@ export function RentalDetailPage() {
|
||||
: rental.cancellationPolicy === 'MODERATE' ? 'Free cancellation up to 5 days before'
|
||||
: 'No refund after booking confirmation';
|
||||
|
||||
const truncatedDescription = rental.description.length > 200
|
||||
? rental.description.slice(0, 200) + '...'
|
||||
: rental.description;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||
{/* Image Gallery */}
|
||||
@@ -88,7 +114,7 @@ export function RentalDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasImages && rental.images.length > 1 && (
|
||||
{hasImages && rental.images.length > 1 && isAuthenticated && (
|
||||
<>
|
||||
<button
|
||||
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) => (
|
||||
<button
|
||||
key={img.id}
|
||||
onClick={() => setActiveImage(i)}
|
||||
className={`aspect-square rounded-xl overflow-hidden cursor-pointer transition-all ${
|
||||
i === activeImage ? 'ring-2 ring-primary-400' : 'hover:ring-2 hover:ring-gray-300'
|
||||
onClick={() => isAuthenticated && setActiveImage(i)}
|
||||
className={`relative aspect-square rounded-xl overflow-hidden transition-all ${
|
||||
!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>
|
||||
))}
|
||||
</div>
|
||||
@@ -158,39 +191,56 @@ export function RentalDetailPage() {
|
||||
{/* Description */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Description</h3>
|
||||
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-wrap">{rental.description}</p>
|
||||
{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>
|
||||
|
||||
{/* Amenities */}
|
||||
{rental.amenities.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rental.amenities.map((amenity, i) => (
|
||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-primary-50 text-primary-700 text-sm font-medium">
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{/* Amenities — auth gated */}
|
||||
{isAuthenticated ? (
|
||||
rental.amenities.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Amenities</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{rental.amenities.map((amenity, i) => (
|
||||
<span key={i} className="px-3 py-1.5 rounded-lg bg-primary-50 text-primary-700 text-sm font-medium">
|
||||
{amenity}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Rules */}
|
||||
{rental.rules.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">House Rules</h3>
|
||||
<ul className="space-y-2">
|
||||
{rental.rules.map((rule, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-gray-400 mt-0.5">--</span>
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
{/* Rules — auth gated */}
|
||||
{isAuthenticated ? (
|
||||
rental.rules.length > 0 && (
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">House Rules</h3>
|
||||
<ul className="space-y-2">
|
||||
{rental.rules.map((rule, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-sm text-gray-600">
|
||||
<span className="text-gray-400 mt-0.5">--</span>
|
||||
{rule}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Cancellation Policy */}
|
||||
{/* Cancellation Policy — always visible */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-2">Cancellation Policy</h3>
|
||||
<Badge variant={rental.cancellationPolicy === 'FLEXIBLE' ? 'success' : rental.cancellationPolicy === 'MODERATE' ? 'warning' : 'error'} size="md">
|
||||
@@ -199,36 +249,43 @@ export function RentalDetailPage() {
|
||||
<p className="text-sm text-gray-500 mt-2">{cancellationLabel}</p>
|
||||
</Card>
|
||||
|
||||
{/* Availability Calendar */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Availability</h3>
|
||||
<AvailabilityCalendar blockedDates={blockedDates} bookedDates={bookedDates} />
|
||||
</Card>
|
||||
{/* Auth-gated sections: calendar, reviews */}
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
{/* Availability Calendar */}
|
||||
<Card>
|
||||
<h3 className="font-semibold text-gray-900 mb-3">Availability</h3>
|
||||
<AvailabilityCalendar blockedDates={blockedDates} bookedDates={bookedDates} />
|
||||
</Card>
|
||||
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Reviews</h3>
|
||||
{rental.avgRating !== undefined && (
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
{rental.avgRating.toFixed(1)}
|
||||
{rental._count?.reviews !== undefined && (
|
||||
<span className="text-gray-400">({rental._count.reviews})</span>
|
||||
{/* Reviews */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h3 className="font-semibold text-gray-900">Reviews</h3>
|
||||
{rental.avgRating !== undefined && (
|
||||
<span className="flex items-center gap-1 text-sm font-medium">
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
{rental.avgRating.toFixed(1)}
|
||||
{rental._count?.reviews !== undefined && (
|
||||
<span className="text-gray-400">({rental._count.reviews})</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No reviews yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map(review => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
{reviews.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No reviews yet.</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{reviews.map(review => (
|
||||
<ReviewCard key={review.id} review={review} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<LoginGate />
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4 text-sm text-gray-400">
|
||||
@@ -246,8 +303,8 @@ export function RentalDetailPage() {
|
||||
|
||||
{/* Right column - Booking and Landlord */}
|
||||
<div className="space-y-6">
|
||||
{/* Booking form */}
|
||||
{!isOwner && (
|
||||
{/* Booking form — auth gated */}
|
||||
{isAuthenticated && !isOwner && (
|
||||
<div className="sticky top-24 space-y-6">
|
||||
<Card padding="lg">
|
||||
<BookingForm rental={rental} />
|
||||
@@ -259,28 +316,32 @@ export function RentalDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Landlord card */}
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-3">Hosted by</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={rental.landlord.fullName} src={rental.landlord.avatar} size="lg" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{rental.landlord.fullName}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{rental.landlord.rating !== undefined && (
|
||||
<>
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium">{rental.landlord.rating}</span>
|
||||
</>
|
||||
{/* Landlord card — auth gated */}
|
||||
{isAuthenticated ? (
|
||||
<Card>
|
||||
<h3 className="text-sm font-medium text-gray-500 mb-3">Hosted by</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar name={rental.landlord.fullName} src={rental.landlord.avatar} size="lg" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-gray-900">{rental.landlord.fullName}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{rental.landlord.rating !== undefined && (
|
||||
<>
|
||||
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
|
||||
<span className="text-sm font-medium">{rental.landlord.rating}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">Joined {formatDate(rental.landlord.createdAt)}</span>
|
||||
</div>
|
||||
{rental.landlord.landlordVerified && (
|
||||
<span className="mt-1"><Badge variant="success" size="sm">Verified</Badge></span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">Joined {formatDate(rental.landlord.createdAt)}</span>
|
||||
</div>
|
||||
{rental.landlord.landlordVerified && (
|
||||
<span className="mt-1"><Badge variant="success" size="sm">Verified</Badge></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card>
|
||||
) : (
|
||||
!isOwner && <LoginGate />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,13 @@ import type { RentalListing } from '../types/rental';
|
||||
import type { PaginatedResponse } from '../types';
|
||||
|
||||
type SortOption = 'newest' | 'price_asc' | 'price_desc' | 'popular';
|
||||
type PeriodFilter = 'ALL' | 'DAILY' | 'MONTHLY';
|
||||
|
||||
export function RentalsPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<SortOption>('newest');
|
||||
const [periodType, setPeriodType] = useState<PeriodFilter>('ALL');
|
||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -28,6 +30,7 @@ export function RentalsPage() {
|
||||
});
|
||||
if (selectedCategory) params.set('category', selectedCategory as string);
|
||||
if (searchQuery) params.set('search', searchQuery);
|
||||
if (periodType !== 'ALL') params.set('periodType', periodType);
|
||||
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<RentalListing>>(`/rentals?${params}`);
|
||||
@@ -39,7 +42,7 @@ export function RentalsPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, sort, selectedCategory, searchQuery]);
|
||||
}, [page, sort, selectedCategory, searchQuery, periodType]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRentals();
|
||||
@@ -102,6 +105,23 @@ export function RentalsPage() {
|
||||
</select>
|
||||
</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 */}
|
||||
{loading ? (
|
||||
<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 { Button } from '../components/ui/Button';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ApiError } from '../api/client';
|
||||
|
||||
export function SignUpPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -15,21 +16,53 @@ export function SignUpPage() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setFieldErrors({});
|
||||
|
||||
if (!validate()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signup({ fullName, email, password });
|
||||
await signup({ fullName: fullName.trim(), email, password });
|
||||
navigate('/profile/create');
|
||||
} 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 {
|
||||
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>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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 />
|
||||
<div>
|
||||
<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)}
|
||||
icon={<Mail className="w-4 h-4" />} required />
|
||||
<div className="relative">
|
||||
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Create a password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
icon={<Lock className="w-4 h-4" />} required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
<div>
|
||||
<div className="relative">
|
||||
<Input label="Password" type={showPassword ? 'text' : 'password'} placeholder="Create a password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
icon={<Lock className="w-4 h-4" />} required />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-[38px] text-gray-400 hover:text-gray-600 cursor-pointer">
|
||||
{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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
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 { ShoppingBag, Calendar, DollarSign, Star } from 'lucide-react';
|
||||
import { ShoppingBag, Calendar, DollarSign, Star, Pause, Play } from 'lucide-react';
|
||||
import { StatCard } from '../../components/ui/StatCard';
|
||||
import { Badge } from '../../components/ui/Badge';
|
||||
import { EarningsChart } from '../../components/charts/EarningsChart';
|
||||
import { api } from '../../api/client';
|
||||
import type { RentalListing, Booking } from '../../types/rental';
|
||||
|
||||
@@ -14,39 +15,56 @@ interface LandlordStats {
|
||||
|
||||
export function LandlordDashboardPage() {
|
||||
const [stats, setStats] = useState<LandlordStats | null>(null);
|
||||
const [rentals, setRentals] = useState<RentalListing[]>([]);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [recentBookings, setRecentBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [rentals, bookings] = await Promise.all([
|
||||
api.get<RentalListing[]>('/rentals/mine'),
|
||||
api.get<Booking[]>('/bookings?role=landlord'),
|
||||
]);
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [rentalsRes, bookingsRes] = await Promise.all([
|
||||
api.get<RentalListing[]>('/rentals/mine'),
|
||||
api.get<Booking[]>('/bookings?role=landlord'),
|
||||
]);
|
||||
|
||||
const totalRentals = rentals.length;
|
||||
const activeBookings = bookings.filter(
|
||||
(b) => b.status === 'CONFIRMED' || b.status === 'ACTIVE'
|
||||
).length;
|
||||
const revenue = bookings
|
||||
.filter((b) => b.status === 'COMPLETED')
|
||||
.reduce((sum, b) => sum + b.totalAmount, 0);
|
||||
setRentals(rentalsRes);
|
||||
setBookings(bookingsRes);
|
||||
|
||||
const ratings = rentals.filter((r) => r.avgRating).map((r) => r.avgRating!);
|
||||
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||
const totalRentals = rentalsRes.length;
|
||||
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 });
|
||||
setRecentBookings(bookings.slice(0, 5));
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const ratings = rentalsRes.filter((r) => r.avgRating).map((r) => r.avgRating!);
|
||||
const avgRating = ratings.length > 0 ? ratings.reduce((a, b) => a + b, 0) / ratings.length : 0;
|
||||
|
||||
setStats({ totalRentals, activeBookings, revenue, avgRating });
|
||||
setRecentBookings(bookingsRes.slice(0, 5));
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
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>;
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div>
|
||||
<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" />
|
||||
</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">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">Recent Bookings</h3>
|
||||
{recentBookings.length === 0 ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { Badge } from '../../components/ui/Badge';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
@@ -93,15 +93,29 @@ export function LandlordListingsPage() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
render: (l: RentalListing) => l.category.replace(/_/g, ' '),
|
||||
},
|
||||
{
|
||||
key: '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',
|
||||
@@ -113,6 +127,16 @@ export function LandlordListingsPage() {
|
||||
</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',
|
||||
header: 'Bookings',
|
||||
@@ -134,13 +158,6 @@ export function LandlordListingsPage() {
|
||||
<Pencil className="w-4 h-4" />
|
||||
</button>
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleToggleStatus(l)}
|
||||
className="p-1.5 rounded-lg hover:bg-gray-100 text-gray-500 hover:text-gray-700 cursor-pointer"
|
||||
title={l.status === 'ACTIVE' ? 'Pause' : 'Activate'}
|
||||
>
|
||||
{l.status === 'ACTIVE' ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(l)}
|
||||
className="p-1.5 rounded-lg hover:bg-red-50 text-gray-500 hover:text-red-600 cursor-pointer"
|
||||
|
||||
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 { LandlordPayoutsPage } from './pages/landlord/LandlordPayoutsPage';
|
||||
import { LandlordReviewsPage } from './pages/landlord/LandlordReviewsPage';
|
||||
import { LandlordSubscriptionPage } from './pages/landlord/LandlordSubscriptionPage';
|
||||
import { TenantDashboardPage } from './pages/TenantDashboardPage';
|
||||
import { AdminRentalsPage } from './pages/admin/AdminRentalsPage';
|
||||
import { AdminBookingsPage } from './pages/admin/AdminBookingsPage';
|
||||
import { AdminRentalPayoutsPage } from './pages/admin/AdminRentalPayoutsPage';
|
||||
@@ -88,6 +90,7 @@ export const router = createBrowserRouter([
|
||||
path: 'dashboard',
|
||||
element: <RequireAuth><DashboardLayout /></RequireAuth>,
|
||||
children: [
|
||||
{ index: true, element: <TenantDashboardPage /> },
|
||||
{ path: 'messages', element: <ChatPage /> },
|
||||
{ path: 'offers', element: <MyOffersPage /> },
|
||||
{ path: 'notifications', element: <NotificationsPage /> },
|
||||
@@ -109,6 +112,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'calendar', element: <LandlordCalendarPage /> },
|
||||
{ path: 'payouts', element: <LandlordPayoutsPage /> },
|
||||
{ path: 'reviews', element: <LandlordReviewsPage /> },
|
||||
{ path: 'subscription', element: <LandlordSubscriptionPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -174,6 +174,35 @@ export interface ModerationLog {
|
||||
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 {
|
||||
totalUsers: number;
|
||||
totalListings: number;
|
||||
|
||||
Reference in New Issue
Block a user