Full-stack short-term rental management platform with: - React/Vite frontend with dark theme dashboard, performance, pricing, reservations, experiments, and settings pages - Fastify API server with auth, platform management, performance tracking, pricing, reservations, experiments, and weekly report endpoints - Playwright-based scraper service with Airbnb adapter (login with MFA, performance metrics, reservations, calendar pricing, price changes) - VRBO adapter scaffold and mock adapter for development - PostgreSQL with Drizzle ORM, migrations, and seed scripts - Job queue with worker for async scraping tasks - AES-256-GCM credential encryption for platform credentials - Session cookie persistence for scraper browser sessions - Docker Compose for PostgreSQL database Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
368 lines
17 KiB
TypeScript
368 lines
17 KiB
TypeScript
import { useState, useMemo } from 'react';
|
|
import { cn, formatCurrency, formatPercent, formatDate } from '@/lib/utils';
|
|
import { PLATFORM_COLORS } from '@/lib/constants';
|
|
import {
|
|
CalendarDays,
|
|
ArrowUpDown,
|
|
Filter,
|
|
ChevronDown,
|
|
} from 'lucide-react';
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from 'recharts';
|
|
|
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
|
|
type ReservationStatus = 'confirmed' | 'completed' | 'cancelled';
|
|
type Platform = 'airbnb' | 'vrbo';
|
|
type SortField = 'guest' | 'checkin' | 'checkout' | 'nights' | 'rate' | 'total' | 'status';
|
|
type SortDir = 'asc' | 'desc';
|
|
|
|
interface Reservation {
|
|
id: string;
|
|
guest: string;
|
|
platform: Platform;
|
|
checkin: string;
|
|
checkout: string;
|
|
nights: number;
|
|
nightlyRate: number;
|
|
totalPayout: number;
|
|
status: ReservationStatus;
|
|
}
|
|
|
|
// ── Mock Data ──────────────────────────────────────────────────────────
|
|
|
|
const MOCK_RESERVATIONS: Reservation[] = [
|
|
{ id: 'r-01', guest: 'Sarah Mitchell', platform: 'airbnb', checkin: '2025-10-04', checkout: '2025-10-08', nights: 4, nightlyRate: 195, totalPayout: 741, status: 'completed' },
|
|
{ id: 'r-02', guest: 'James Park', platform: 'vrbo', checkin: '2025-10-12', checkout: '2025-10-15', nights: 3, nightlyRate: 185, totalPayout: 527, status: 'completed' },
|
|
{ id: 'r-03', guest: 'Emily Rodriguez', platform: 'airbnb', checkin: '2025-10-22', checkout: '2025-10-27', nights: 5, nightlyRate: 210, totalPayout: 999, status: 'completed' },
|
|
{ id: 'r-04', guest: 'Michael Chen', platform: 'airbnb', checkin: '2025-11-01', checkout: '2025-11-04', nights: 3, nightlyRate: 175, totalPayout: 499, status: 'completed' },
|
|
{ id: 'r-05', guest: 'Lisa Thompson', platform: 'vrbo', checkin: '2025-11-10', checkout: '2025-11-17', nights: 7, nightlyRate: 165, totalPayout: 1098, status: 'completed' },
|
|
{ id: 'r-06', guest: 'David Kim', platform: 'airbnb', checkin: '2025-11-22', checkout: '2025-11-24', nights: 2, nightlyRate: 220, totalPayout: 418, status: 'cancelled' },
|
|
{ id: 'r-07', guest: 'Amanda Foster', platform: 'vrbo', checkin: '2025-12-05', checkout: '2025-12-09', nights: 4, nightlyRate: 230, totalPayout: 874, status: 'completed' },
|
|
{ id: 'r-08', guest: 'Robert Johnson', platform: 'airbnb', checkin: '2025-12-18', checkout: '2025-12-25', nights: 7, nightlyRate: 250, totalPayout: 1663, status: 'completed' },
|
|
{ id: 'r-09', guest: 'Jennifer Lee', platform: 'airbnb', checkin: '2025-12-28', checkout: '2025-12-31', nights: 3, nightlyRate: 245, totalPayout: 698, status: 'completed' },
|
|
{ id: 'r-10', guest: 'Chris Martinez', platform: 'vrbo', checkin: '2026-01-03', checkout: '2026-01-06', nights: 3, nightlyRate: 180, totalPayout: 513, status: 'completed' },
|
|
{ id: 'r-11', guest: 'Natalie Wright', platform: 'airbnb', checkin: '2026-01-15', checkout: '2026-01-20', nights: 5, nightlyRate: 190, totalPayout: 903, status: 'completed' },
|
|
{ id: 'r-12', guest: 'Kevin Brown', platform: 'vrbo', checkin: '2026-01-28', checkout: '2026-02-01', nights: 4, nightlyRate: 175, totalPayout: 665, status: 'completed' },
|
|
{ id: 'r-13', guest: 'Patricia Davis', platform: 'airbnb', checkin: '2026-02-07', checkout: '2026-02-12', nights: 5, nightlyRate: 200, totalPayout: 950, status: 'completed' },
|
|
{ id: 'r-14', guest: 'Thomas Wilson', platform: 'airbnb', checkin: '2026-02-20', checkout: '2026-02-22', nights: 2, nightlyRate: 215, totalPayout: 409, status: 'cancelled' },
|
|
{ id: 'r-15', guest: 'Rachel Garcia', platform: 'vrbo', checkin: '2026-03-01', checkout: '2026-03-05', nights: 4, nightlyRate: 205, totalPayout: 779, status: 'confirmed' },
|
|
{ id: 'r-16', guest: 'Daniel Taylor', platform: 'airbnb', checkin: '2026-03-10', checkout: '2026-03-14', nights: 4, nightlyRate: 210, totalPayout: 798, status: 'confirmed' },
|
|
{ id: 'r-17', guest: 'Stephanie Moore', platform: 'vrbo', checkin: '2026-03-20', checkout: '2026-03-26', nights: 6, nightlyRate: 195, totalPayout: 1112, status: 'confirmed' },
|
|
{ id: 'r-18', guest: 'Brian Anderson', platform: 'airbnb', checkin: '2026-03-28', checkout: '2026-03-31', nights: 3, nightlyRate: 225, totalPayout: 641, status: 'confirmed' },
|
|
];
|
|
|
|
const STATUS_STYLES: Record<ReservationStatus, string> = {
|
|
confirmed: 'bg-green-500/10 text-green-400',
|
|
completed: 'bg-blue-500/10 text-blue-400',
|
|
cancelled: 'bg-red-500/10 text-red-400',
|
|
};
|
|
|
|
const PLATFORM_DOT: Record<Platform, string> = {
|
|
airbnb: PLATFORM_COLORS.airbnb,
|
|
vrbo: PLATFORM_COLORS.vrbo,
|
|
};
|
|
|
|
// ── Component ──────────────────────────────────────────────────────────
|
|
|
|
export default function Reservations() {
|
|
const [platformFilter, setPlatformFilter] = useState<Platform | 'all'>('all');
|
|
const [statusFilter, setStatusFilter] = useState<ReservationStatus | 'all'>('all');
|
|
const [sortField, setSortField] = useState<SortField>('checkin');
|
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
|
|
|
function toggleSort(field: SortField) {
|
|
if (sortField === field) {
|
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
} else {
|
|
setSortField(field);
|
|
setSortDir('asc');
|
|
}
|
|
}
|
|
|
|
const filtered = useMemo(() => {
|
|
let list = [...MOCK_RESERVATIONS];
|
|
if (platformFilter !== 'all') list = list.filter((r) => r.platform === platformFilter);
|
|
if (statusFilter !== 'all') list = list.filter((r) => r.status === statusFilter);
|
|
|
|
list.sort((a, b) => {
|
|
const dir = sortDir === 'asc' ? 1 : -1;
|
|
switch (sortField) {
|
|
case 'guest': return a.guest.localeCompare(b.guest) * dir;
|
|
case 'checkin': return (new Date(a.checkin).getTime() - new Date(b.checkin).getTime()) * dir;
|
|
case 'checkout': return (new Date(a.checkout).getTime() - new Date(b.checkout).getTime()) * dir;
|
|
case 'nights': return (a.nights - b.nights) * dir;
|
|
case 'rate': return (a.nightlyRate - b.nightlyRate) * dir;
|
|
case 'total': return (a.totalPayout - b.totalPayout) * dir;
|
|
case 'status': return a.status.localeCompare(b.status) * dir;
|
|
default: return 0;
|
|
}
|
|
});
|
|
return list;
|
|
}, [platformFilter, statusFilter, sortField, sortDir]);
|
|
|
|
// Monthly revenue chart data
|
|
const monthlyRevenue = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
MOCK_RESERVATIONS.filter((r) => r.status !== 'cancelled').forEach((r) => {
|
|
const d = new Date(r.checkin);
|
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
map.set(key, (map.get(key) || 0) + r.totalPayout);
|
|
});
|
|
return Array.from(map.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([month, revenue]) => {
|
|
const [y, m] = month.split('-');
|
|
const label = new Date(Number(y), Number(m) - 1).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
year: '2-digit',
|
|
});
|
|
return { month: label, revenue };
|
|
});
|
|
}, []);
|
|
|
|
// Monthly occupancy summary
|
|
const monthlyOccupancy = useMemo(() => {
|
|
const map = new Map<string, number>();
|
|
MOCK_RESERVATIONS.filter((r) => r.status !== 'cancelled').forEach((r) => {
|
|
const d = new Date(r.checkin);
|
|
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
|
map.set(key, (map.get(key) || 0) + r.nights);
|
|
});
|
|
return Array.from(map.entries())
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([month, nights]) => {
|
|
const [y, m] = month.split('-');
|
|
const daysInMonth = new Date(Number(y), Number(m), 0).getDate();
|
|
const label = new Date(Number(y), Number(m) - 1).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
year: '2-digit',
|
|
});
|
|
return { month: label, nights, daysInMonth, occupancy: nights / daysInMonth };
|
|
});
|
|
}, []);
|
|
|
|
const totalRevenue = filtered
|
|
.filter((r) => r.status !== 'cancelled')
|
|
.reduce((sum, r) => sum + r.totalPayout, 0);
|
|
|
|
const SortHeader = ({ field, label, align }: { field: SortField; label: string; align?: string }) => (
|
|
<th
|
|
className={cn('pb-2 pr-4 font-medium cursor-pointer select-none hover:text-text-primary transition-colors', align)}
|
|
onClick={() => toggleSort(field)}
|
|
>
|
|
<span className="inline-flex items-center gap-1">
|
|
{label}
|
|
{sortField === field && (
|
|
<ArrowUpDown className="w-3 h-3 text-accent" />
|
|
)}
|
|
</span>
|
|
</th>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3">
|
|
<CalendarDays className="w-5 h-5 text-accent" />
|
|
<h1 className="text-lg font-semibold text-text-primary">Reservations</h1>
|
|
<span className="text-xs text-text-muted bg-surface border border-border rounded-full px-2 py-0.5">
|
|
{filtered.length} reservations
|
|
</span>
|
|
</div>
|
|
|
|
{/* Revenue chart + occupancy summary */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
{/* Bar chart */}
|
|
<div className="lg:col-span-2 bg-surface border border-border rounded-lg p-5">
|
|
<h2 className="text-sm font-medium text-text-primary mb-4">Revenue by Month</h2>
|
|
<div className="h-56">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={monthlyRevenue} barCategoryGap="20%">
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#262626" />
|
|
<XAxis
|
|
dataKey="month"
|
|
tick={{ fill: '#737373', fontSize: 12 }}
|
|
axisLine={{ stroke: '#262626' }}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
tick={{ fill: '#737373', fontSize: 12 }}
|
|
axisLine={{ stroke: '#262626' }}
|
|
tickLine={false}
|
|
tickFormatter={(v) => `$${(v / 1000).toFixed(1)}k`}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#141414',
|
|
border: '1px solid #262626',
|
|
borderRadius: 8,
|
|
fontSize: 13,
|
|
}}
|
|
labelStyle={{ color: '#fafafa' }}
|
|
formatter={(value: number) => [formatCurrency(value), 'Revenue']}
|
|
/>
|
|
<Bar dataKey="revenue" fill="#22c55e" radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Occupancy cards */}
|
|
<div className="bg-surface border border-border rounded-lg p-5">
|
|
<h2 className="text-sm font-medium text-text-primary mb-4">Monthly Occupancy</h2>
|
|
<div className="space-y-3">
|
|
{monthlyOccupancy.map((m) => (
|
|
<div key={m.month} className="flex items-center justify-between">
|
|
<span className="text-sm text-text-muted">{m.month}</span>
|
|
<div className="flex items-center gap-3 flex-1 ml-4">
|
|
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-accent rounded-full transition-all"
|
|
style={{ width: `${Math.min(m.occupancy * 100, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<span className="font-mono text-sm text-text-primary w-12 text-right">
|
|
{formatPercent(m.occupancy)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 pt-3 border-t border-border">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-text-muted">Total Revenue</span>
|
|
<span className="font-mono font-semibold text-accent">
|
|
{formatCurrency(totalRevenue)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<div className="flex items-center gap-1.5 text-text-muted">
|
|
<Filter className="w-4 h-4" />
|
|
<span className="text-xs uppercase tracking-wide">Filters</span>
|
|
</div>
|
|
|
|
{/* Platform filter */}
|
|
<div className="relative">
|
|
<select
|
|
value={platformFilter}
|
|
onChange={(e) => setPlatformFilter(e.target.value as Platform | 'all')}
|
|
className={cn(
|
|
'appearance-none rounded-md bg-surface border border-border pl-3 pr-8 py-1.5 text-sm text-text-primary',
|
|
'focus:outline-none focus:ring-1 focus:ring-accent cursor-pointer'
|
|
)}
|
|
>
|
|
<option value="all">All Platforms</option>
|
|
<option value="airbnb">Airbnb</option>
|
|
<option value="vrbo">VRBO</option>
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
|
|
</div>
|
|
|
|
{/* Status filter */}
|
|
<div className="relative">
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value as ReservationStatus | 'all')}
|
|
className={cn(
|
|
'appearance-none rounded-md bg-surface border border-border pl-3 pr-8 py-1.5 text-sm text-text-primary',
|
|
'focus:outline-none focus:ring-1 focus:ring-accent cursor-pointer'
|
|
)}
|
|
>
|
|
<option value="all">All Statuses</option>
|
|
<option value="confirmed">Confirmed</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted pointer-events-none" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reservations table */}
|
|
<div className="bg-surface border border-border rounded-lg overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-left text-xs text-text-muted border-b border-border px-5">
|
|
<SortHeader field="guest" label="Guest" />
|
|
<th className="pb-2 pr-4 font-medium">Platform</th>
|
|
<SortHeader field="checkin" label="Check-in" />
|
|
<SortHeader field="checkout" label="Check-out" />
|
|
<SortHeader field="nights" label="Nights" align="text-right" />
|
|
<SortHeader field="rate" label="Nightly Rate" align="text-right" />
|
|
<SortHeader field="total" label="Total Payout" align="text-right" />
|
|
<SortHeader field="status" label="Status" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((r) => (
|
|
<tr
|
|
key={r.id}
|
|
className="border-b border-border/50 last:border-0 hover:bg-white/[0.02] transition-colors"
|
|
>
|
|
<td className="px-5 py-3 font-medium text-text-primary whitespace-nowrap">
|
|
{r.guest}
|
|
</td>
|
|
<td className="py-3 pr-4 whitespace-nowrap">
|
|
<span className="inline-flex items-center gap-2 text-text-primary capitalize">
|
|
<span
|
|
className="w-2 h-2 rounded-full shrink-0"
|
|
style={{ backgroundColor: PLATFORM_DOT[r.platform] }}
|
|
/>
|
|
{r.platform === 'vrbo' ? 'VRBO' : 'Airbnb'}
|
|
</span>
|
|
</td>
|
|
<td className="py-3 pr-4 text-text-primary whitespace-nowrap">
|
|
{formatDate(r.checkin)}
|
|
</td>
|
|
<td className="py-3 pr-4 text-text-primary whitespace-nowrap">
|
|
{formatDate(r.checkout)}
|
|
</td>
|
|
<td className="py-3 pr-4 text-right font-mono text-text-primary">
|
|
{r.nights}
|
|
</td>
|
|
<td className="py-3 pr-4 text-right font-mono text-text-primary">
|
|
{formatCurrency(r.nightlyRate)}
|
|
</td>
|
|
<td className="py-3 pr-4 text-right font-mono font-semibold text-text-primary">
|
|
{formatCurrency(r.totalPayout)}
|
|
</td>
|
|
<td className="py-3 pr-5">
|
|
<span
|
|
className={cn(
|
|
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium capitalize',
|
|
STATUS_STYLES[r.status]
|
|
)}
|
|
>
|
|
{r.status}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={8} className="px-5 py-8 text-center text-text-muted text-sm">
|
|
No reservations match the selected filters.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|