Add rental system: listings, bookings, payments, payouts, reviews

Full rental marketplace with 6 categories (apartment, house, car, motorcycle, bicycle, ebike).
Booking workflow: create → confirm → pay → active → complete → payout.
Landlord dashboard, admin moderation, availability calendar, Stripe Connect payouts.
14 QA bugs found and fixed including validator schemas, API response types, HTTP methods.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
delta-lynx-89e8
2026-02-22 15:33:29 -08:00
parent 8961fa701a
commit dbbbbd26f4
90 changed files with 11052 additions and 124 deletions

View File

@@ -0,0 +1,160 @@
import { useState, useEffect, useCallback } from 'react';
import { DollarSign, ExternalLink } from 'lucide-react';
import { DataTable } from '../../components/ui/DataTable';
import { Badge } from '../../components/ui/Badge';
import { GradientButton } from '../../components/ui/GradientButton';
import { StatCard } from '../../components/ui/StatCard';
import { api } from '../../api/client';
import type { Payout } from '../../types/rental';
interface AccountStatus {
connected: boolean;
chargesEnabled: boolean;
payoutsEnabled: boolean;
onboardingUrl?: string;
}
export function LandlordPayoutsPage() {
const [payouts, setPayouts] = useState<Payout[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [accountStatus, setAccountStatus] = useState<AccountStatus | null>(null);
const [connectLoading, setConnectLoading] = useState(false);
const fetchPayouts = useCallback(async () => {
try {
const params = new URLSearchParams({ page: String(page), pageSize: '20' });
const res = await api.get<Payout[]>(`/payouts?${params}`);
setPayouts(res);
setTotal(res.length);
} catch {
// silently fail
}
}, [page]);
useEffect(() => {
fetchPayouts();
api.get<AccountStatus>('/payouts/account-status').then(setAccountStatus).catch(() => {});
}, [fetchPayouts]);
const handleSetupStripe = async () => {
setConnectLoading(true);
try {
const { url } = await api.post<{ url: string }>('/payouts/setup-account');
window.location.href = url;
} catch {
setConnectLoading(false);
}
};
const statusBadge = (status: string) => {
const map: Record<string, 'success' | 'warning' | 'info' | 'error' | 'default'> = {
COMPLETED: 'success',
PROCESSING: 'info',
PENDING: 'warning',
FAILED: 'error',
};
return <Badge variant={map[status] || 'default'} size="sm">{status}</Badge>;
};
const totalEarned = payouts.filter((p) => p.status === 'COMPLETED').reduce((sum, p) => sum + p.netAmount, 0);
const pendingAmount = payouts.filter((p) => p.status === 'PENDING' || p.status === 'PROCESSING').reduce((sum, p) => sum + p.netAmount, 0);
return (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">Payouts</h1>
{/* Stripe Connect status */}
{accountStatus && !accountStatus.payoutsEnabled && (
<div className="bg-amber-50 border border-amber-200 rounded-2xl p-5 mb-6">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="font-semibold text-amber-900 mb-1">
{accountStatus.connected ? 'Complete Stripe Setup' : 'Set Up Payouts'}
</h3>
<p className="text-sm text-amber-700">
{accountStatus.connected
? 'Your Stripe account needs additional information before payouts can be processed.'
: 'Connect your Stripe account to receive payouts for your rental bookings.'}
</p>
</div>
<GradientButton size="sm" onClick={handleSetupStripe} isLoading={connectLoading}>
<ExternalLink className="w-4 h-4 mr-1.5" />
{accountStatus.connected ? 'Complete Setup' : 'Connect Stripe'}
</GradientButton>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<StatCard icon={DollarSign} label="Total Earned" value={`$${totalEarned.toFixed(2)}`} color="green" />
<StatCard icon={DollarSign} label="Pending" value={`$${pendingAmount.toFixed(2)}`} color="yellow" />
</div>
<DataTable
columns={[
{
key: 'booking',
header: 'Booking',
render: (p: Payout) => (
<div>
<p className="font-medium text-gray-900 truncate max-w-xs">
{p.booking?.rentalListing?.title || 'Unknown Listing'}
</p>
<p className="text-xs text-gray-400">
{p.booking?.tenant?.fullName || 'Unknown Tenant'}
</p>
</div>
),
},
{
key: 'dates',
header: 'Period',
render: (p: Payout) => (
<div className="text-sm">
{p.booking ? (
<>
<div>{new Date(p.booking.startDate).toLocaleDateString()}</div>
<div className="text-gray-400">to {new Date(p.booking.endDate).toLocaleDateString()}</div>
</>
) : (
<span className="text-gray-400">--</span>
)}
</div>
),
},
{
key: 'gross',
header: 'Gross',
render: (p: Payout) => <span className="text-sm">${p.grossAmount.toFixed(2)}</span>,
},
{
key: 'commission',
header: 'Commission',
render: (p: Payout) => <span className="text-sm text-gray-500">-${p.commissionAmount.toFixed(2)}</span>,
},
{
key: 'net',
header: 'Net',
render: (p: Payout) => <span className="font-medium text-green-700">${p.netAmount.toFixed(2)}</span>,
},
{
key: 'status',
header: 'Status',
render: (p: Payout) => statusBadge(p.status),
},
{
key: 'date',
header: 'Date',
render: (p: Payout) => new Date(p.createdAt).toLocaleDateString(),
},
]}
data={payouts}
total={total}
page={page}
pageSize={20}
onPageChange={setPage}
/>
</div>
);
}