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:
delta-lynx-89e8
2026-02-22 16:19:33 -08:00
parent 68beca8f30
commit dcd2dcb841
24 changed files with 1352 additions and 167 deletions

View File

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

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

View File

@@ -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() {

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

View File

@@ -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) => (

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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