Initial commit: STR Optimization Manager MVP

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>
This commit is contained in:
2026-03-23 15:03:21 -04:00
parent 4735c73b3a
commit d4c714fadc
76 changed files with 18465 additions and 0 deletions

15
apps/frontend/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>STR Optimization Manager</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
</head>
<body class="bg-background text-text-primary">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,41 @@
{
"name": "@str/frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5173",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-router-dom": "^7.1.0",
"recharts": "^2.15.0",
"@tanstack/react-query": "^5.62.0",
"clsx": "^2.1.0",
"tailwind-merge": "^2.6.0",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.468.0",
"date-fns": "^4.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"@radix-ui/react-popover": "^1.1.0",
"@radix-ui/react-select": "^2.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-dropdown-menu": "^2.1.0",
"@radix-ui/react-toast": "^1.2.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

42
apps/frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import Shell from '@/components/layout/Shell';
import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
import Performance from '@/pages/Performance';
import Pricing from '@/pages/Pricing';
import Experiments from '@/pages/Experiments';
import Reservations from '@/pages/Reservations';
import SettingsPage from '@/pages/Settings';
function App() {
const { isAuthenticated, isLoading, login, logout } = useAuth();
if (isLoading) {
return (
<div className="h-screen flex items-center justify-center">
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!isAuthenticated) {
return <Login onLogin={login} />;
}
return (
<Routes>
<Route element={<Shell onLogout={logout} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/performance" element={<Performance />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/experiments" element={<Experiments />} />
<Route path="/reservations" element={<Reservations />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,138 @@
import { Outlet, NavLink, useLocation } from 'react-router-dom';
import {
LayoutDashboard,
BarChart3,
DollarSign,
FlaskConical,
CalendarCheck,
Settings,
LogOut,
Menu,
X,
} from 'lucide-react';
import { useState } from 'react';
import { cn } from '@/lib/utils';
const navItems = [
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ path: '/performance', icon: BarChart3, label: 'Performance' },
{ path: '/pricing', icon: DollarSign, label: 'Pricing' },
{ path: '/experiments', icon: FlaskConical, label: 'Experiments' },
{ path: '/reservations', icon: CalendarCheck, label: 'Reservations' },
{ path: '/settings', icon: Settings, label: 'Settings' },
];
export default function Shell({ onLogout }: { onLogout: () => void }) {
const [mobileOpen, setMobileOpen] = useState(false);
const location = useLocation();
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop Sidebar */}
<aside className="hidden md:flex w-56 flex-col bg-surface border-r border-border">
<div className="p-4 border-b border-border">
<h1 className="text-sm font-semibold tracking-wide text-accent">STR OPTIMIZER</h1>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent/10 text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-white/5'
)
}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</nav>
<div className="p-2 border-t border-border">
<button
onClick={onLogout}
className="flex items-center gap-3 px-3 py-2 rounded-md text-sm text-text-muted hover:text-danger w-full transition-colors"
>
<LogOut className="w-4 h-4" />
Logout
</button>
</div>
</aside>
{/* Mobile Header */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="md:hidden flex items-center justify-between p-3 bg-surface border-b border-border">
<h1 className="text-sm font-semibold tracking-wide text-accent">STR OPTIMIZER</h1>
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="p-1 text-text-muted"
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</button>
</header>
{/* Mobile Menu Overlay */}
{mobileOpen && (
<div className="md:hidden absolute inset-0 z-50 bg-background/95 pt-14">
<nav className="p-4 space-y-2">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-md text-base',
isActive ? 'bg-accent/10 text-accent' : 'text-text-muted'
)
}
>
<item.icon className="w-5 h-5" />
{item.label}
</NavLink>
))}
<button
onClick={onLogout}
className="flex items-center gap-3 px-4 py-3 rounded-md text-base text-text-muted w-full"
>
<LogOut className="w-5 h-5" />
Logout
</button>
</nav>
</div>
)}
{/* Main Content */}
<main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet />
</main>
{/* Mobile Bottom Nav */}
<nav className="md:hidden flex items-center justify-around border-t border-border bg-surface py-2">
{navItems.slice(0, 5).map((item) => (
<NavLink
key={item.path}
to={item.path}
end={item.path === '/'}
className={({ isActive }) =>
cn(
'flex flex-col items-center gap-0.5 px-2 py-1 text-[10px]',
isActive ? 'text-accent' : 'text-text-muted'
)
}
>
<item.icon className="w-4 h-4" />
{item.label}
</NavLink>
))}
</nav>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect, useCallback } from 'react';
import { api, ApiError } from '@/lib/api';
interface AuthState {
isAuthenticated: boolean;
username: string | null;
isLoading: boolean;
}
export function useAuth() {
const [state, setState] = useState<AuthState>({
isAuthenticated: false,
username: null,
isLoading: true,
});
const checkSession = useCallback(async () => {
try {
const data = await api.me();
setState({ isAuthenticated: true, username: data.username, isLoading: false });
} catch {
setState({ isAuthenticated: false, username: null, isLoading: false });
}
}, []);
useEffect(() => {
checkSession();
}, [checkSession]);
const login = async (username: string, password: string) => {
try {
await api.login(username, password);
setState({ isAuthenticated: true, username, isLoading: false });
return true;
} catch (err) {
if (err instanceof ApiError) throw err;
throw new Error('Login failed');
}
};
const logout = async () => {
await api.logout();
setState({ isAuthenticated: false, username: null, isLoading: false });
};
return { ...state, login, logout, checkSession };
}

View File

@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: #0a0a0a;
--surface: #141414;
--border: #262626;
--accent: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--text-primary: #fafafa;
--text-muted: #737373;
}
* {
border-color: var(--border);
}
body {
background-color: var(--background);
color: var(--text-primary);
font-family: 'DM Sans', system-ui, sans-serif;
}
}
@layer utilities {
.font-mono-data {
font-family: 'JetBrains Mono', 'IBM Plex Mono', monospace;
font-variant-numeric: tabular-nums;
}
}
/* Skeleton loading animation */
@keyframes skeleton-pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 0.1; }
}
.animate-skeleton {
animation: skeleton-pulse 2s ease-in-out infinite;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--background);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}

View File

@@ -0,0 +1,119 @@
const BASE_URL = '/api/v1';
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = 'ApiError';
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText }));
throw new ApiError(res.status, body.message || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
// Auth
login: (username: string, password: string) =>
request<{ success: boolean }>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
logout: () => request('/auth/logout', { method: 'POST' }),
me: () => request<{ username: string }>('/auth/me'),
// Platforms
getPlatforms: () => request<any[]>('/platforms'),
updateCredentials: (id: string, credentials: { email: string; password: string }) =>
request(`/platforms/${id}/credentials`, {
method: 'PUT',
body: JSON.stringify(credentials),
}),
testPlatform: (id: string) =>
request<any>(`/platforms/${id}/test`, { method: 'POST' }),
loginPlatform: (id: string, credentials?: { email: string; password: string }) =>
request<any>(`/platforms/${id}/login`, {
method: 'POST',
body: JSON.stringify(credentials || {}),
}),
checkSession: (id: string) =>
request<any>(`/platforms/${id}/session`),
triggerScrape: (id: string) =>
request<any>(`/platforms/${id}/scrape`, { method: 'POST' }),
getScrapeJobs: (id: string) => request<any[]>(`/platforms/${id}/scrape-jobs`),
// Performance
getSnapshots: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/performance/snapshots${qs ? `?${qs}` : ''}`);
},
getPerformanceSummary: () => request<any>('/performance/summary'),
getPerformanceTrends: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/performance/trends${qs ? `?${qs}` : ''}`);
},
// Pricing
getPricingCalendar: (params?: { platform?: string; from?: string; to?: string }) => {
const qs = new URLSearchParams(params as Record<string, string>).toString();
return request<any[]>(`/pricing/calendar${qs ? `?${qs}` : ''}`);
},
previewPriceChanges: (changes: any[]) =>
request<any>('/pricing/preview', {
method: 'POST',
body: JSON.stringify({ changes }),
}),
applyPriceChanges: (previewToken: string) =>
request<any>('/pricing/apply', {
method: 'POST',
body: JSON.stringify({ previewToken }),
}),
getPriceChanges: (params?: Record<string, string>) => {
const qs = new URLSearchParams(params).toString();
return request<any[]>(`/pricing/changes${qs ? `?${qs}` : ''}`);
},
// Experiments
getExperiments: () => request<any[]>('/experiments'),
createExperiment: (data: any) =>
request<any>('/experiments', {
method: 'POST',
body: JSON.stringify(data),
}),
updateExperiment: (id: string, data: any) =>
request<any>(`/experiments/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
getExperimentAnalysis: (id: string) => request<any>(`/experiments/${id}/analysis`),
// Reservations
getReservations: (params?: Record<string, string>) => {
const qs = new URLSearchParams(params).toString();
return request<any[]>(`/reservations${qs ? `?${qs}` : ''}`);
},
getReservationSummary: () => request<any>('/reservations/summary'),
// Reports
sendWeeklyReport: () => request('/reports/weekly/send', { method: 'POST' }),
previewWeeklyReport: () => request<any>('/reports/weekly/preview'),
// Health
health: () => request<any>('/health'),
};
export { ApiError };

View File

@@ -0,0 +1,27 @@
export const CHART_COLORS = {
primary: '#22c55e',
secondary: '#3b82f6',
tertiary: '#a855f7',
quaternary: '#f59e0b',
airbnb: '#ff5a5f',
vrbo: '#3b5998',
} as const;
export const DATE_PRESETS = [
{ label: '7D', days: 7 },
{ label: '30D', days: 30 },
{ label: '90D', days: 90 },
{ label: 'YTD', days: -1 }, // special: calculate from Jan 1
] as const;
export const PLATFORM_LABELS: Record<string, string> = {
airbnb: 'Airbnb',
vrbo: 'VRBO',
mock: 'Mock',
};
export const PLATFORM_COLORS: Record<string, string> = {
airbnb: '#ff5a5f',
vrbo: '#3b5998',
mock: '#22c55e',
};

View File

@@ -0,0 +1,52 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(amount);
}
export function formatPercent(value: number): string {
return `${(value * 100).toFixed(1)}%`;
}
export function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
export function formatDate(date: string | Date): string {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(new Date(date));
}
export function formatDateShort(date: string | Date): string {
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
}).format(new Date(date));
}
export function getRelativeTime(date: string | Date): string {
const now = new Date();
const then = new Date(date);
const diffMs = now.getTime() - then.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}

View File

@@ -0,0 +1,25 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);

View File

@@ -0,0 +1,306 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { LineChart, Line, ResponsiveContainer } from 'recharts';
import {
TrendingUp,
TrendingDown,
Play,
Loader2,
CheckCircle2,
XCircle,
Eye,
DollarSign,
Percent,
CalendarCheck,
} from 'lucide-react';
import { cn, formatCurrency, formatPercent, formatDateShort, getRelativeTime } from '@/lib/utils';
import { api } from '@/lib/api';
import { CHART_COLORS, PLATFORM_LABELS, PLATFORM_COLORS } from '@/lib/constants';
// ---------------------------------------------------------------------------
// Mock / fallback data
// ---------------------------------------------------------------------------
const MOCK_SUMMARY = {
occupancyRate: 0.78,
occupancyTrend: 0.04,
avgDailyRate: 189,
adrTrend: 0.06,
revenueMtd: 4720,
revenueTrend: 0.12,
searchViews30d: 1843,
viewsTrend: -0.03,
};
const MOCK_PLATFORMS = [
{
id: 'airbnb',
name: 'airbnb',
enabled: true,
lastScrapeAt: new Date(Date.now() - 3600000 * 2).toISOString(),
lastScrapeStatus: 'success',
occupancyRate: 0.82,
avgDailyRate: 195,
revenueMtd: 2960,
searchViews30d: 1120,
},
{
id: 'vrbo',
name: 'vrbo',
enabled: true,
lastScrapeAt: new Date(Date.now() - 3600000 * 5).toISOString(),
lastScrapeStatus: 'success',
occupancyRate: 0.71,
avgDailyRate: 178,
revenueMtd: 1760,
searchViews30d: 723,
},
];
const MOCK_RESERVATIONS = [
{ id: '1', guestName: 'Sarah M.', platform: 'airbnb', checkIn: '2026-03-25', checkOut: '2026-03-28', payout: 585 },
{ id: '2', guestName: 'James T.', platform: 'vrbo', checkIn: '2026-03-20', checkOut: '2026-03-23', payout: 534 },
{ id: '3', guestName: 'Emily R.', platform: 'airbnb', checkIn: '2026-03-15', checkOut: '2026-03-18', payout: 612 },
{ id: '4', guestName: 'David L.', platform: 'airbnb', checkIn: '2026-03-10', checkOut: '2026-03-14', payout: 780 },
{ id: '5', guestName: 'Lisa K.', platform: 'vrbo', checkIn: '2026-03-06', checkOut: '2026-03-09', payout: 498 },
];
function generateSparklineData() {
const data = [];
let value = 3;
for (let i = 0; i < 90; i++) {
value = Math.max(0, value + (Math.random() - 0.45) * 2);
data.push({ day: i, bookings: Math.round(value) });
}
return data;
}
const MOCK_SPARKLINE = generateSparklineData();
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface KpiCardProps {
label: string;
value: string;
trend: number;
icon: React.ElementType;
}
function KpiCard({ label, value, trend, icon: Icon }: KpiCardProps) {
const isPositive = trend >= 0;
return (
<div className="bg-surface border border-border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-text-muted text-xs uppercase tracking-wide">{label}</span>
<Icon className="w-4 h-4 text-text-muted" />
</div>
<div className="text-2xl font-mono-data text-text-primary">{value}</div>
<div className={cn('flex items-center gap-1 mt-1 text-xs', isPositive ? 'text-green-400' : 'text-red-400')}>
{isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span className="font-mono-data">{isPositive ? '+' : ''}{(trend * 100).toFixed(1)}%</span>
</div>
</div>
);
}
function PlatformCard({ platform }: { platform: any }) {
const queryClient = useQueryClient();
const scrapeMutation = useMutation({
mutationFn: () => api.triggerScrape(platform.id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
},
});
const color = PLATFORM_COLORS[platform.name] || CHART_COLORS.primary;
const label = PLATFORM_LABELS[platform.name] || platform.name;
const isSuccess = platform.lastScrapeStatus === 'success';
return (
<div className="bg-surface border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-sm font-medium text-text-primary">{label}</span>
</div>
<div className={cn('flex items-center gap-1 text-xs', isSuccess ? 'text-green-400' : 'text-red-400')}>
{isSuccess ? <CheckCircle2 className="w-3 h-3" /> : <XCircle className="w-3 h-3" />}
<span>{platform.lastScrapeAt ? getRelativeTime(platform.lastScrapeAt) : 'never'}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-text-muted">Occupancy</span>
<div className="font-mono-data text-text-primary text-sm">{formatPercent(platform.occupancyRate ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">ADR</span>
<div className="font-mono-data text-text-primary text-sm">{formatCurrency(platform.avgDailyRate ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">Revenue MTD</span>
<div className="font-mono-data text-text-primary text-sm">{formatCurrency(platform.revenueMtd ?? 0)}</div>
</div>
<div>
<span className="text-text-muted">Views (30d)</span>
<div className="font-mono-data text-text-primary text-sm">{(platform.searchViews30d ?? 0).toLocaleString()}</div>
</div>
</div>
<button
onClick={() => scrapeMutation.mutate()}
disabled={scrapeMutation.isPending}
className={cn(
'w-full flex items-center justify-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs',
'text-text-muted hover:text-text-primary hover:border-accent/50 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{scrapeMutation.isPending ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Play className="w-3 h-3" />
)}
{scrapeMutation.isPending ? 'Running...' : 'Run Scrape Now'}
</button>
</div>
);
}
// ---------------------------------------------------------------------------
// Dashboard
// ---------------------------------------------------------------------------
export default function Dashboard() {
const summaryQuery = useQuery({
queryKey: ['performance-summary'],
queryFn: () => api.getPerformanceSummary(),
retry: false,
});
const reservationsQuery = useQuery({
queryKey: ['reservations'],
queryFn: () => api.getReservations(),
retry: false,
});
const platformsQuery = useQuery({
queryKey: ['platforms'],
queryFn: () => api.getPlatforms(),
retry: false,
});
const summary = summaryQuery.data ?? MOCK_SUMMARY;
const reservations = (reservationsQuery.data ?? MOCK_RESERVATIONS).slice(0, 5);
const platforms = platformsQuery.data ?? MOCK_PLATFORMS;
return (
<div className="space-y-6">
{/* Page header */}
<div>
<h2 className="text-lg font-semibold text-text-primary">Dashboard</h2>
<p className="text-xs text-text-muted mt-0.5">Overview of your short-term rental performance</p>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiCard
label="Occupancy Rate"
value={formatPercent(summary.occupancyRate ?? 0)}
trend={summary.occupancyTrend ?? 0}
icon={Percent}
/>
<KpiCard
label="Avg Daily Rate"
value={formatCurrency(summary.avgDailyRate ?? 0)}
trend={summary.adrTrend ?? 0}
icon={DollarSign}
/>
<KpiCard
label="Revenue MTD"
value={formatCurrency(summary.revenueMtd ?? 0)}
trend={summary.revenueTrend ?? 0}
icon={CalendarCheck}
/>
<KpiCard
label="Search Views (30d)"
value={(summary.searchViews30d ?? 0).toLocaleString()}
trend={summary.viewsTrend ?? 0}
icon={Eye}
/>
</div>
{/* Platform comparison + Sparkline row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3">
{/* Platform cards */}
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3">
{platforms.map((p: any) => (
<PlatformCard key={p.id} platform={p} />
))}
</div>
{/* Booking sparkline */}
<div className="bg-surface border border-border rounded-lg p-4 flex flex-col">
<span className="text-text-muted text-xs uppercase tracking-wide mb-2">Bookings (90 days)</span>
<div className="flex-1 min-h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={MOCK_SPARKLINE}>
<Line
type="monotone"
dataKey="bookings"
stroke={CHART_COLORS.primary}
strokeWidth={1.5}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Recent reservations */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<span className="text-text-muted text-xs uppercase tracking-wide">Recent Reservations</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-text-muted text-xs uppercase border-b border-border">
<th className="text-left px-4 py-2 font-medium">Guest</th>
<th className="text-left px-4 py-2 font-medium">Platform</th>
<th className="text-left px-4 py-2 font-medium">Dates</th>
<th className="text-right px-4 py-2 font-medium">Payout</th>
</tr>
</thead>
<tbody>
{reservations.map((r: any) => {
const color = PLATFORM_COLORS[r.platformId || r.platform] || CHART_COLORS.primary;
const label = PLATFORM_LABELS[r.platformId || r.platform] || r.platformId || r.platform;
return (
<tr key={r.id} className="border-b border-border last:border-0 hover:bg-white/[0.02] transition-colors">
<td className="px-4 py-2.5 text-text-primary">{r.guestName}</td>
<td className="px-4 py-2.5">
<span className="inline-flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: color }} />
<span className="text-text-muted">{label}</span>
</span>
</td>
<td className="px-4 py-2.5 text-text-muted whitespace-nowrap">
{formatDateShort(r.checkIn)} - {formatDateShort(r.checkOut)}
</td>
<td className="px-4 py-2.5 text-right font-mono-data text-text-primary">
{formatCurrency(Number(r.totalPayout || r.payout || 0))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,530 @@
import { useState } from 'react';
import { cn, formatDate, formatCurrency, formatPercent } from '@/lib/utils';
import {
FlaskConical,
Plus,
X,
ChevronDown,
ChevronRight,
CheckCircle2,
XCircle,
LinkIcon,
} from 'lucide-react';
// ── Types ──────────────────────────────────────────────────────────────
type ExperimentStatus = 'active' | 'completed' | 'cancelled';
interface PriceChange {
id: string;
date: string;
platform: string;
oldPrice: number;
newPrice: number;
changePercent: number;
}
interface MetricComparison {
label: string;
before: number;
after: number;
format: 'currency' | 'percent' | 'number';
}
interface Experiment {
id: string;
name: string;
hypothesis: string;
status: ExperimentStatus;
startDate: string;
endDate: string;
conclusion: string;
priceChanges: PriceChange[];
metrics: MetricComparison[];
}
// ── Mock Data ──────────────────────────────────────────────────────────
const MOCK_EXPERIMENTS: Experiment[] = [
{
id: 'exp-1',
name: 'Weekend Premium Pricing',
hypothesis:
'Increasing Friday-Sunday rates by 15% during peak season will increase weekend revenue without hurting occupancy below 75%.',
status: 'active',
startDate: '2026-03-01',
endDate: '2026-04-15',
conclusion: '',
priceChanges: [
{ id: 'pc-1', date: '2026-03-07', platform: 'Airbnb', oldPrice: 195, newPrice: 224, changePercent: 14.9 },
{ id: 'pc-2', date: '2026-03-08', platform: 'Airbnb', oldPrice: 195, newPrice: 224, changePercent: 14.9 },
{ id: 'pc-3', date: '2026-03-14', platform: 'VRBO', oldPrice: 185, newPrice: 213, changePercent: 15.1 },
{ id: 'pc-4', date: '2026-03-15', platform: 'VRBO', oldPrice: 185, newPrice: 213, changePercent: 15.1 },
],
metrics: [
{ label: 'Avg Nightly Rate', before: 190, after: 218, format: 'currency' },
{ label: 'Weekend Occupancy', before: 0.82, after: 0.78, format: 'percent' },
{ label: 'Weekend Revenue', before: 3120, after: 3408, format: 'currency' },
],
},
{
id: 'exp-2',
name: 'Midweek Discount Strategy',
hypothesis:
'Offering a 10% discount on Tuesday-Thursday stays will increase midweek bookings and improve overall occupancy from 58% to 70%.',
status: 'completed',
startDate: '2026-01-15',
endDate: '2026-02-28',
conclusion:
'Midweek occupancy rose from 58% to 67%, short of the 70% target. Revenue per available night increased by 4.2%. Recommend continuing at a 7% discount.',
priceChanges: [
{ id: 'pc-5', date: '2026-01-21', platform: 'Airbnb', oldPrice: 175, newPrice: 158, changePercent: -9.7 },
{ id: 'pc-6', date: '2026-01-22', platform: 'Airbnb', oldPrice: 175, newPrice: 158, changePercent: -9.7 },
{ id: 'pc-7', date: '2026-02-04', platform: 'VRBO', oldPrice: 170, newPrice: 153, changePercent: -10.0 },
],
metrics: [
{ label: 'Midweek Occupancy', before: 0.58, after: 0.67, format: 'percent' },
{ label: 'RevPAN', before: 101, after: 106, format: 'currency' },
{ label: 'Total Midweek Revenue', before: 4410, after: 4620, format: 'currency' },
],
},
{
id: 'exp-3',
name: 'Minimum Stay Reduction',
hypothesis:
'Reducing the minimum stay from 3 nights to 2 nights will capture more short-trip bookings and increase monthly occupancy by 8%.',
status: 'completed',
startDate: '2025-11-01',
endDate: '2025-12-31',
conclusion:
'Occupancy increased by 11% and total revenue grew 6.3%. Cleaning costs rose 18% due to higher turnover. Net profit impact was +3.1%. Keeping 2-night minimum.',
priceChanges: [
{ id: 'pc-8', date: '2025-11-05', platform: 'Airbnb', oldPrice: 180, newPrice: 180, changePercent: 0 },
{ id: 'pc-9', date: '2025-11-05', platform: 'VRBO', oldPrice: 175, newPrice: 175, changePercent: 0 },
],
metrics: [
{ label: 'Monthly Occupancy', before: 0.64, after: 0.75, format: 'percent' },
{ label: 'Total Revenue', before: 8640, after: 9180, format: 'currency' },
{ label: 'Avg Booking Length', before: 4.2, after: 3.1, format: 'number' },
],
},
{
id: 'exp-4',
name: 'Dynamic Pricing by Lead Time',
hypothesis:
'Applying a 20% premium for bookings made within 3 days of check-in will capture last-minute willingness to pay.',
status: 'cancelled',
startDate: '2026-02-01',
endDate: '2026-02-28',
conclusion: 'Cancelled after 10 days due to platform policy conflicts with Airbnb smart pricing. Need to disable smart pricing first.',
priceChanges: [
{ id: 'pc-10', date: '2026-02-03', platform: 'Airbnb', oldPrice: 195, newPrice: 234, changePercent: 20.0 },
],
metrics: [
{ label: 'Last-Minute Bookings', before: 4, after: 1, format: 'number' },
{ label: 'Avg Nightly Rate', before: 195, after: 234, format: 'currency' },
],
},
];
// ── Helpers ─────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<ExperimentStatus, string> = {
active: 'bg-green-500/10 text-green-400',
completed: 'bg-blue-500/10 text-blue-400',
cancelled: 'bg-neutral-500/10 text-neutral-400',
};
function formatMetric(value: number, format: MetricComparison['format']) {
if (format === 'currency') return formatCurrency(value);
if (format === 'percent') return formatPercent(value);
return value.toFixed(1);
}
function metricDelta(before: number, after: number, format: MetricComparison['format']) {
const diff = after - before;
const pct = before !== 0 ? ((diff / before) * 100).toFixed(1) : '0.0';
const sign = diff >= 0 ? '+' : '';
if (format === 'currency') return `${sign}${formatCurrency(diff)} (${sign}${pct}%)`;
if (format === 'percent') return `${sign}${(diff * 100).toFixed(1)}pp`;
return `${sign}${diff.toFixed(1)}`;
}
// ── Component ──────────────────────────────────────────────────────────
export default function Experiments() {
const [experiments, setExperiments] = useState<Experiment[]>(MOCK_EXPERIMENTS);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
// Create form state
const [formName, setFormName] = useState('');
const [formHypothesis, setFormHypothesis] = useState('');
const [formStart, setFormStart] = useState('');
const [formEnd, setFormEnd] = useState('');
function handleCreate() {
if (!formName || !formStart || !formEnd) return;
const newExp: Experiment = {
id: `exp-${Date.now()}`,
name: formName,
hypothesis: formHypothesis,
status: 'active',
startDate: formStart,
endDate: formEnd,
conclusion: '',
priceChanges: [],
metrics: [],
};
setExperiments((prev) => [newExp, ...prev]);
setFormName('');
setFormHypothesis('');
setFormStart('');
setFormEnd('');
setShowCreateModal(false);
}
function handleStatusChange(id: string, newStatus: ExperimentStatus) {
setExperiments((prev) =>
prev.map((e) => (e.id === id ? { ...e, status: newStatus } : e))
);
}
function handleConclusionChange(id: string, conclusion: string) {
setExperiments((prev) =>
prev.map((e) => (e.id === id ? { ...e, conclusion } : e))
);
}
const inputClass = cn(
'w-full rounded-md bg-surface border border-border px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors'
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<FlaskConical className="w-5 h-5 text-accent" />
<h1 className="text-lg font-semibold text-text-primary">Experiments</h1>
<span className="text-xs text-text-muted bg-surface border border-border rounded-full px-2 py-0.5">
{experiments.length}
</span>
</div>
<button
onClick={() => setShowCreateModal(true)}
className={cn(
'inline-flex items-center gap-2 rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors'
)}
>
<Plus className="w-4 h-4" />
New Experiment
</button>
</div>
{/* Experiment Cards */}
<div className="space-y-3">
{experiments.map((exp) => {
const isExpanded = expandedId === exp.id;
return (
<div
key={exp.id}
className="bg-surface border border-border rounded-lg overflow-hidden"
>
{/* Card header */}
<button
onClick={() => setExpandedId(isExpanded ? null : exp.id)}
className="w-full flex items-center gap-4 px-5 py-4 text-left hover:bg-white/[0.02] transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-text-muted shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-text-muted shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center gap-2">
<span className="font-medium text-text-primary truncate">
{exp.name}
</span>
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium w-fit capitalize',
STATUS_STYLES[exp.status]
)}
>
{exp.status}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-text-muted">
<span>
{formatDate(exp.startDate)} &ndash; {formatDate(exp.endDate)}
</span>
<span className="inline-flex items-center gap-1">
<LinkIcon className="w-3 h-3" />
{exp.priceChanges.length} price changes
</span>
</div>
</div>
</button>
{/* Expanded detail */}
{isExpanded && (
<div className="border-t border-border px-5 py-5 space-y-6">
{/* Hypothesis */}
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-1.5">
Hypothesis
</h3>
<p className="text-sm text-text-primary leading-relaxed">
{exp.hypothesis}
</p>
</div>
{/* Linked price changes table */}
{exp.priceChanges.length > 0 && (
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-2">
Linked Price Changes
</h3>
<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">
<th className="pb-2 pr-4 font-medium">Date</th>
<th className="pb-2 pr-4 font-medium">Platform</th>
<th className="pb-2 pr-4 font-medium text-right">Old Price</th>
<th className="pb-2 pr-4 font-medium text-right">New Price</th>
<th className="pb-2 font-medium text-right">Change</th>
</tr>
</thead>
<tbody>
{exp.priceChanges.map((pc) => (
<tr key={pc.id} className="border-b border-border/50 last:border-0">
<td className="py-2 pr-4 text-text-primary">
{formatDate(pc.date)}
</td>
<td className="py-2 pr-4 text-text-primary">{pc.platform}</td>
<td className="py-2 pr-4 text-right font-mono text-text-muted">
{formatCurrency(pc.oldPrice)}
</td>
<td className="py-2 pr-4 text-right font-mono text-text-primary">
{formatCurrency(pc.newPrice)}
</td>
<td
className={cn(
'py-2 text-right font-mono',
pc.changePercent > 0
? 'text-green-400'
: pc.changePercent < 0
? 'text-red-400'
: 'text-text-muted'
)}
>
{pc.changePercent > 0 ? '+' : ''}
{pc.changePercent.toFixed(1)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Metric comparison */}
{exp.metrics.length > 0 && (
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-2">
Before / After Comparison
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{exp.metrics.map((m) => {
const improved =
m.format === 'percent' || m.format === 'number'
? m.after >= m.before
: m.after >= m.before;
return (
<div
key={m.label}
className="bg-background border border-border rounded-md p-3"
>
<div className="text-xs text-text-muted mb-2">{m.label}</div>
<div className="flex items-end justify-between">
<div>
<div className="text-xs text-text-muted">Before</div>
<div className="font-mono text-sm text-text-primary">
{formatMetric(m.before, m.format)}
</div>
</div>
<div className="text-lg text-text-muted mx-2">&rarr;</div>
<div className="text-right">
<div className="text-xs text-text-muted">After</div>
<div className="font-mono text-sm text-text-primary">
{formatMetric(m.after, m.format)}
</div>
</div>
</div>
<div
className={cn(
'text-xs font-mono mt-2 text-right',
improved ? 'text-green-400' : 'text-red-400'
)}
>
{metricDelta(m.before, m.after, m.format)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Conclusion */}
<div>
<h3 className="text-xs text-text-muted uppercase tracking-wide mb-1.5">
Conclusion
</h3>
{exp.status === 'active' ? (
<p className="text-sm text-text-muted italic">
Experiment still running. Conclusion can be added once completed.
</p>
) : (
<textarea
value={exp.conclusion}
onChange={(e) => handleConclusionChange(exp.id, e.target.value)}
rows={3}
placeholder="Write your conclusion here..."
className={cn(inputClass, 'resize-y')}
/>
)}
</div>
{/* Status actions */}
{exp.status === 'active' && (
<div className="flex items-center gap-3 pt-2 border-t border-border">
<button
onClick={() => handleStatusChange(exp.id, 'completed')}
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors'
)}
>
<CheckCircle2 className="w-4 h-4" />
Complete
</button>
<button
onClick={() => handleStatusChange(exp.id, 'cancelled')}
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-neutral-500/10 text-neutral-400 hover:bg-neutral-500/20 transition-colors'
)}
>
<XCircle className="w-4 h-4" />
Cancel
</button>
</div>
)}
</div>
)}
</div>
);
})}
</div>
{/* Create Experiment Modal */}
{showCreateModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 px-4">
<div className="bg-surface border border-border rounded-lg w-full max-w-lg p-6 space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">
New Experiment
</h2>
<button
onClick={() => setShowCreateModal(false)}
className="text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Name
</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Weekend Premium Pricing"
className={inputClass}
/>
</div>
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Hypothesis
</label>
<textarea
value={formHypothesis}
onChange={(e) => setFormHypothesis(e.target.value)}
rows={3}
placeholder="Describe what you expect to happen and why..."
className={cn(inputClass, 'resize-y')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Start Date
</label>
<input
type="date"
value={formStart}
onChange={(e) => setFormStart(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
End Date
</label>
<input
type="date"
value={formEnd}
onChange={(e) => setFormEnd(e.target.value)}
className={inputClass}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<button
onClick={() => setShowCreateModal(false)}
className="rounded-md px-4 py-2 text-sm text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={!formName || !formStart || !formEnd}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Create Experiment
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useState, type FormEvent } from 'react';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
interface LoginProps {
onLogin: (username: string, password: string) => Promise<boolean>;
}
export default function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
await onLogin(username, password);
} catch (err: any) {
setError(err?.message || 'Invalid credentials');
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-background px-4">
<div className="w-full max-w-sm">
<div className="bg-surface border border-border rounded-lg p-8">
<h1 className="text-center text-lg font-semibold tracking-wide text-accent mb-1">
STR Optimizer
</h1>
<p className="text-center text-text-muted text-xs mb-8">
Short-Term Rental Performance
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Username
</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
autoComplete="username"
className={cn(
'w-full rounded-md bg-surface border border-border px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors'
)}
placeholder="admin"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs text-text-muted uppercase tracking-wide mb-1.5">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
className={cn(
'w-full rounded-md bg-surface border border-border px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors'
)}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-xs text-red-400 bg-red-400/10 rounded-md px-3 py-2">
{error}
</p>
)}
<button
type="submit"
disabled={loading}
className={cn(
'w-full rounded-md bg-accent text-black font-semibold py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed',
'flex items-center justify-center gap-2'
)}
>
{loading && <Loader2 className="w-4 h-4 animate-spin" />}
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,602 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
BarChart,
Bar,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import { ArrowUpDown, TrendingUp } from 'lucide-react';
import { format, subDays, startOfYear, isAfter, parseISO, startOfWeek } from 'date-fns';
import { cn, formatCurrency, formatPercent, formatNumber } from '@/lib/utils';
import { api } from '@/lib/api';
import { DATE_PRESETS, CHART_COLORS } from '@/lib/constants';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TrendPoint {
date: string;
platform: string;
views_search: number;
conversion_rate: number;
bookings_count: number;
occupancy_rate: number;
avg_daily_rate: number;
}
interface SnapshotRow {
date: string;
platform: string;
views: number;
clicks: number;
ctr: number;
bookings: number;
occupancy: number;
adr: number;
revenue: number;
}
type SortField = keyof SnapshotRow;
type SortDir = 'asc' | 'desc';
type PlatformFilter = 'all' | 'airbnb' | 'vrbo';
// ---------------------------------------------------------------------------
// Mock data generator (90 days, realistic STR metrics w/ upward trend)
// ---------------------------------------------------------------------------
function generateMockTrends(): TrendPoint[] {
const points: TrendPoint[] = [];
const today = new Date();
const platforms: string[] = ['airbnb', 'vrbo'];
for (let i = 89; i >= 0; i--) {
const d = subDays(today, i);
const dateStr = format(d, 'yyyy-MM-dd');
const progress = (90 - i) / 90; // 0 -> 1 over 90 days
const dayOfWeek = d.getDay();
const weekendBoost = dayOfWeek === 0 || dayOfWeek === 6 ? 1.15 : 1;
for (const platform of platforms) {
const platformMul = platform === 'airbnb' ? 1.3 : 1;
const noise = () => 0.85 + Math.random() * 0.3;
const views = Math.round(
(400 + progress * 600 + Math.random() * 400) * platformMul * weekendBoost * noise()
);
const ctr = (0.03 + progress * 0.015 + Math.random() * 0.01) * weekendBoost;
const bookings = Math.round(
(0.4 + progress * 0.4 + Math.random() * 0.6) * platformMul * weekendBoost
);
const occupancy = Math.min(
0.95,
(0.55 + progress * 0.15 + Math.random() * 0.1) * weekendBoost
);
const adr = Math.round(
(155 + progress * 45 + Math.random() * 30) * (weekendBoost > 1 ? 1.2 : 1)
);
points.push({
date: dateStr,
platform,
views_search: views,
conversion_rate: parseFloat(ctr.toFixed(4)),
bookings_count: bookings,
occupancy_rate: parseFloat(occupancy.toFixed(3)),
avg_daily_rate: adr,
});
}
}
return points;
}
function generateMockSnapshots(): SnapshotRow[] {
const rows: SnapshotRow[] = [];
const today = new Date();
const platforms: string[] = ['airbnb', 'vrbo'];
for (let i = 89; i >= 0; i--) {
const d = subDays(today, i);
const dateStr = format(d, 'yyyy-MM-dd');
const progress = (90 - i) / 90;
const dayOfWeek = d.getDay();
const weekendBoost = dayOfWeek === 0 || dayOfWeek === 6 ? 1.15 : 1;
for (const platform of platforms) {
const platformMul = platform === 'airbnb' ? 1.3 : 1;
const noise = () => 0.85 + Math.random() * 0.3;
const views = Math.round(
(400 + progress * 600 + Math.random() * 400) * platformMul * weekendBoost * noise()
);
const clicks = Math.round(views * (0.08 + Math.random() * 0.04));
const ctr = clicks / views;
const bookings = Math.round(
(0.4 + progress * 0.4 + Math.random() * 0.6) * platformMul * weekendBoost
);
const occupancy = Math.min(
0.95,
(0.55 + progress * 0.15 + Math.random() * 0.1) * weekendBoost
);
const adr = Math.round(
(155 + progress * 45 + Math.random() * 30) * (weekendBoost > 1 ? 1.2 : 1)
);
const revenue = Math.round(adr * occupancy * 30 * (Math.random() * 0.2 + 0.9));
rows.push({
date: dateStr,
platform,
views,
clicks,
ctr: parseFloat(ctr.toFixed(4)),
bookings,
occupancy: parseFloat(occupancy.toFixed(3)),
adr,
revenue,
});
}
}
return rows;
}
const MOCK_TRENDS = generateMockTrends();
const MOCK_SNAPSHOTS = generateMockSnapshots();
// ---------------------------------------------------------------------------
// Chart wrapper
// ---------------------------------------------------------------------------
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="bg-surface border border-border rounded-lg p-4">
<h3 className="text-sm font-medium text-text-primary mb-3">{title}</h3>
<div className="h-64">{children}</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function ChartTooltip({ active, payload, label, valueFormatter }: any) {
if (!active || !payload?.length) return null;
return (
<div className="bg-surface border border-border rounded-md px-3 py-2 text-xs shadow-lg">
<p className="text-text-muted mb-1">{label}</p>
{payload.map((entry: any, i: number) => (
<p key={i} style={{ color: entry.color }} className="font-mono">
{valueFormatter ? valueFormatter(entry.value) : entry.value}
</p>
))}
</div>
);
}
// ---------------------------------------------------------------------------
// Main page
// ---------------------------------------------------------------------------
export default function Performance() {
const [dateRange, setDateRange] = useState(30);
const [platformFilter, setPlatformFilter] = useState<PlatformFilter>('all');
const [sortField, setSortField] = useState<SortField>('date');
const [sortDir, setSortDir] = useState<SortDir>('desc');
// ---- Data fetching with fallback to mock data ----
const { data: rawTrends } = useQuery({
queryKey: ['performanceTrends'],
queryFn: () => api.getPerformanceTrends(),
retry: false,
});
const { data: rawSnapshots } = useQuery({
queryKey: ['snapshots'],
queryFn: () => api.getSnapshots(),
retry: false,
});
const trends: TrendPoint[] = rawTrends?.length
? rawTrends.map((r: any) => ({
date: r.date || r.periodLabel,
platform: r.platformId || r.platform,
views_search: Number(r.viewsSearch ?? r.views_search ?? 0),
conversion_rate: Number(r.conversionRate ?? r.conversion_rate ?? 0),
bookings_count: Number(r.bookingsCount ?? r.bookings_count ?? 0),
occupancy_rate: Number(r.occupancyRate ?? r.occupancy_rate ?? 0) / 100,
avg_daily_rate: Number(r.avgDailyRate ?? r.avg_daily_rate ?? 0),
}))
: MOCK_TRENDS;
const snapshots: SnapshotRow[] = rawSnapshots?.length
? rawSnapshots.map((r: any) => ({
date: r.periodLabel || r.date,
platform: r.platformId || r.platform,
views: Number(r.viewsSearch ?? r.views ?? 0),
clicks: Number(r.viewsListing ?? r.clicks ?? 0),
ctr: Number(r.conversionRate ?? r.ctr ?? 0) / 100,
bookings: Number(r.bookingsCount ?? r.bookings ?? 0),
occupancy: Number(r.occupancyRate ?? r.occupancy ?? 0) / 100,
adr: Number(r.avgDailyRate ?? r.adr ?? 0),
revenue: Number(r.revenueTotal ?? r.revenue ?? 0),
}))
: MOCK_SNAPSHOTS;
// ---- Compute cutoff date ----
const cutoffDate = useMemo(() => {
if (dateRange === -1) return startOfYear(new Date());
return subDays(new Date(), dateRange);
}, [dateRange]);
// ---- Filter helpers ----
const filterData = <T extends { date: string; platform: string }>(data: T[]): T[] =>
data.filter((d) => {
const dateOk = isAfter(parseISO(d.date), cutoffDate);
const platOk = platformFilter === 'all' || d.platform === platformFilter;
return dateOk && platOk;
});
// ---- Filtered + aggregated data ----
const filteredTrends = useMemo(() => {
const filtered = filterData(trends);
// Aggregate by date (sum across platforms if "all")
const byDate = new Map<string, Omit<TrendPoint, 'platform'>>();
for (const row of filtered) {
const existing = byDate.get(row.date);
if (existing) {
existing.views_search += row.views_search;
existing.conversion_rate = (existing.conversion_rate + row.conversion_rate) / 2;
existing.bookings_count += row.bookings_count;
existing.occupancy_rate = (existing.occupancy_rate + row.occupancy_rate) / 2;
existing.avg_daily_rate = (existing.avg_daily_rate + row.avg_daily_rate) / 2;
} else {
byDate.set(row.date, { ...row });
}
}
return Array.from(byDate.values()).sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trends, cutoffDate, platformFilter]);
// Weekly bookings aggregation
const weeklyBookings = useMemo(() => {
const weekMap = new Map<string, number>();
for (const row of filteredTrends) {
const weekStart = format(startOfWeek(parseISO(row.date), { weekStartsOn: 1 }), 'MMM d');
weekMap.set(weekStart, (weekMap.get(weekStart) || 0) + row.bookings_count);
}
return Array.from(weekMap.entries()).map(([week, count]) => ({ week, bookings: count }));
}, [filteredTrends]);
// Chart-ready date labels
const chartTrends = useMemo(
() =>
filteredTrends.map((d) => ({
...d,
label: format(parseISO(d.date), 'MMM d'),
})),
[filteredTrends]
);
// ---- Table data ----
const tableData = useMemo(() => {
const filtered = filterData(snapshots);
const sorted = [...filtered].sort((a, b) => {
const aVal = a[sortField];
const bVal = b[sortField];
if (typeof aVal === 'string' && typeof bVal === 'string')
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
return sortDir === 'asc'
? (aVal as number) - (bVal as number)
: (bVal as number) - (aVal as number);
});
return sorted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snapshots, cutoffDate, platformFilter, sortField, sortDir]);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDir('desc');
}
};
// ---- Recharts common props ----
const gridProps = { stroke: '#262626', strokeDasharray: '3 3' };
const xAxisProps = {
dataKey: 'label' as const,
stroke: '#262626',
tick: { fill: '#737373', fontSize: 11 },
tickLine: false,
};
const yAxisProps = {
stroke: '#262626',
tick: { fill: '#737373', fontSize: 11 },
tickLine: false,
width: 50,
};
// ---- Render ----
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" />
<h2 className="text-lg font-semibold text-text-primary">Performance</h2>
</div>
{/* Controls bar */}
<div className="flex flex-wrap items-center gap-3">
{/* Date range presets */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{DATE_PRESETS.map((preset) => {
const active = dateRange === preset.days;
return (
<button
key={preset.label}
onClick={() => setDateRange(preset.days)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-accent/10 text-accent border-accent'
: 'bg-surface text-text-muted hover:text-text-primary'
)}
>
{preset.label}
</button>
);
})}
</div>
{/* Platform filter */}
<div className="flex items-center rounded-md border border-border overflow-hidden">
{(['all', 'airbnb', 'vrbo'] as const).map((p) => {
const active = platformFilter === p;
const label = p === 'all' ? 'All' : p === 'airbnb' ? 'Airbnb' : 'VRBO';
return (
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={cn(
'px-3 py-1.5 text-xs font-medium transition-colors',
active
? 'bg-accent/10 text-accent border-accent'
: 'bg-surface text-text-muted hover:text-text-primary'
)}
>
{label}
</button>
);
})}
</div>
</div>
{/* Charts grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Search Views */}
<ChartCard title="Search Views">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<ChartTooltip valueFormatter={formatNumber} />} />
<Line
type="monotone"
dataKey="views_search"
stroke={CHART_COLORS.primary}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: CHART_COLORS.primary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
{/* Click-Through Rate */}
<ChartCard title="Click-Through Rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis
{...yAxisProps}
tickFormatter={(v: number) => `${(v * 100).toFixed(1)}%`}
/>
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatPercent(v)} />}
/>
<Line
type="monotone"
dataKey="conversion_rate"
stroke={CHART_COLORS.secondary}
strokeWidth={2}
dot={false}
activeDot={{ r: 4, fill: CHART_COLORS.secondary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
{/* Bookings per Week */}
<ChartCard title="Bookings per Week">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={weeklyBookings}>
<CartesianGrid {...gridProps} />
<XAxis
dataKey="week"
stroke="#262626"
tick={{ fill: '#737373', fontSize: 11 }}
tickLine={false}
/>
<YAxis {...yAxisProps} />
<Tooltip content={<ChartTooltip valueFormatter={formatNumber} />} />
<Bar dataKey="bookings" fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</ChartCard>
{/* Occupancy Rate */}
<ChartCard title="Occupancy Rate">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartTrends}>
<defs>
<linearGradient id="occupancyGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={CHART_COLORS.primary} stopOpacity={0.3} />
<stop offset="100%" stopColor={CHART_COLORS.primary} stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis
{...yAxisProps}
tickFormatter={(v: number) => `${(v * 100).toFixed(0)}%`}
domain={[0.4, 1]}
/>
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatPercent(v)} />}
/>
<Area
type="monotone"
dataKey="occupancy_rate"
stroke={CHART_COLORS.primary}
strokeWidth={2}
fill="url(#occupancyGradient)"
activeDot={{ r: 4, fill: CHART_COLORS.primary }}
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
{/* Avg Daily Rate */}
<ChartCard title="Avg Daily Rate">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartTrends}>
<CartesianGrid {...gridProps} />
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} tickFormatter={(v: number) => `$${v}`} />
<Tooltip
content={<ChartTooltip valueFormatter={(v: number) => formatCurrency(v)} />}
/>
<Line
type="monotone"
dataKey="avg_daily_rate"
stroke={CHART_COLORS.quaternary}
strokeWidth={2}
dot={{ r: 2, fill: CHART_COLORS.quaternary, strokeWidth: 0 }}
activeDot={{ r: 5, fill: CHART_COLORS.quaternary }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Data table */}
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-text-primary">Snapshot Data</h3>
<p className="text-xs text-text-muted mt-0.5">
{formatNumber(tableData.length)} records
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
{(
[
['date', 'Date'],
['platform', 'Platform'],
['views', 'Views'],
['clicks', 'Clicks'],
['ctr', 'CTR'],
['bookings', 'Bookings'],
['occupancy', 'Occupancy'],
['adr', 'ADR'],
['revenue', 'Revenue'],
] as [SortField, string][]
).map(([field, label]) => (
<th
key={field}
onClick={() => toggleSort(field)}
className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-text-muted cursor-pointer select-none hover:text-text-primary transition-colors whitespace-nowrap"
>
<span className="inline-flex items-center gap-1">
{label}
<ArrowUpDown
className={cn(
'w-3 h-3',
sortField === field ? 'text-accent' : 'opacity-30'
)}
/>
</span>
</th>
))}
</tr>
</thead>
<tbody>
{tableData.slice(0, 50).map((row, i) => (
<tr
key={`${row.date}-${row.platform}-${i}`}
className="border-b border-border last:border-0 hover:bg-white/[0.02] transition-colors"
>
<td className="px-4 py-2 text-text-primary whitespace-nowrap">
{format(parseISO(row.date), 'MMM d, yyyy')}
</td>
<td className="px-4 py-2">
<span
className={cn(
'text-xs font-medium px-1.5 py-0.5 rounded',
row.platform === 'airbnb'
? 'bg-[#ff5a5f]/10 text-[#ff5a5f]'
: 'bg-[#3b5998]/10 text-[#3b5998]'
)}
>
{row.platform === 'airbnb' ? 'Airbnb' : 'VRBO'}
</span>
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatNumber(row.views)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatNumber(row.clicks)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatPercent(row.ctr)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{row.bookings}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatPercent(row.occupancy)}
</td>
<td className="px-4 py-2 font-mono text-text-primary">
{formatCurrency(row.adr)}
</td>
<td className="px-4 py-2 font-mono text-accent font-medium">
{formatCurrency(row.revenue)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{tableData.length > 50 && (
<div className="px-4 py-2 border-t border-border text-xs text-text-muted">
Showing 50 of {formatNumber(tableData.length)} records
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,714 @@
import { useState, useMemo, useCallback, type MouseEvent } from 'react';
import {
ChevronLeft,
ChevronRight,
X,
AlertTriangle,
ArrowUpRight,
ArrowDownRight,
Filter,
SlidersHorizontal,
} from 'lucide-react';
import {
startOfMonth,
endOfMonth,
eachDayOfInterval,
format,
addMonths,
subMonths,
getDay,
isSameDay,
isSameMonth,
isWeekend,
addDays,
isWithinInterval,
startOfDay,
} from 'date-fns';
import { cn, formatCurrency } from '@/lib/utils';
import { PLATFORM_LABELS, PLATFORM_COLORS } from '@/lib/constants';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface DayPricing {
date: Date;
prices: Record<string, number>; // platform -> price
}
interface PriceChangeRecord {
id: string;
date: Date;
platform: string;
oldPrice: number;
newPrice: number;
changedBy: string;
note: string;
experiment?: string;
changedAt: Date;
}
interface PreviewRow {
date: Date;
platform: string;
currentPrice: number;
newPrice: number;
delta: number;
}
// ---------------------------------------------------------------------------
// Mock data generator
// ---------------------------------------------------------------------------
const PLATFORMS = ['airbnb', 'vrbo'] as const;
function generateMockPricing(baseDate: Date): DayPricing[] {
const start = addDays(startOfMonth(baseDate), -7);
const end = addDays(endOfMonth(addMonths(baseDate, 1)), 7);
const days = eachDayOfInterval({ start, end });
return days.map((date) => {
const base = 180 + Math.sin(date.getTime() / 864000000) * 40;
const weekendBoost = isWeekend(date) ? 1.2 : 1.0;
const airbnbPrice = Math.round(base * weekendBoost + (Math.random() - 0.5) * 30);
const vrboPrice = Math.round(airbnbPrice * (0.92 + Math.random() * 0.08));
return {
date,
prices: {
airbnb: Math.max(150, Math.min(300, airbnbPrice)),
vrbo: Math.max(150, Math.min(300, vrboPrice)),
},
};
});
}
function generateMockChangeLog(): PriceChangeRecord[] {
const records: PriceChangeRecord[] = [];
const now = new Date();
for (let i = 0; i < 12; i++) {
const d = addDays(now, -i * 3);
const platform = i % 2 === 0 ? 'airbnb' : 'vrbo';
const oldPrice = 180 + Math.round(Math.random() * 60);
const newPrice = oldPrice + Math.round((Math.random() - 0.4) * 40);
records.push({
id: `chg-${i}`,
date: addDays(d, 2),
platform,
oldPrice,
newPrice,
changedBy: 'admin',
note: i % 3 === 0 ? 'Weekend rate bump' : '',
experiment: i % 4 === 0 ? 'EXP-001' : undefined,
changedAt: d,
});
}
return records;
}
const MOCK_EXPERIMENTS = [
{ id: 'exp-1', name: 'EXP-001: Weekend +15%' },
{ id: 'exp-2', name: 'EXP-002: Midweek Discount' },
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function priceDeviationClass(price: number, avg: number): string {
const ratio = price / avg;
if (ratio < 0.9) return 'text-accent';
if (ratio <= 1.1) return 'text-text-primary';
if (ratio <= 1.25) return 'text-warning';
return 'text-danger';
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function Pricing() {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDays, setSelectedDays] = useState<Date[]>([]);
const [shiftAnchor, setShiftAnchor] = useState<Date | null>(null);
// Price change panel state
const [newPrice, setNewPrice] = useState<string>('');
const [selectedPlatforms, setSelectedPlatforms] = useState<Record<string, boolean>>({
airbnb: true,
vrbo: true,
});
const [note, setNote] = useState('');
const [linkedExperiment, setLinkedExperiment] = useState('');
// Preview modal
const [previewRows, setPreviewRows] = useState<PreviewRow[] | null>(null);
// Change log filter
const [logPlatformFilter, setLogPlatformFilter] = useState<string>('all');
// Mock data
const [pricingData, setPricingData] = useState<DayPricing[]>(() => generateMockPricing(new Date()));
const [changeLog, setChangeLog] = useState<PriceChangeRecord[]>(() => generateMockChangeLog());
// Derived
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarDays = useMemo(() => eachDayOfInterval({ start: monthStart, end: monthEnd }), [monthStart.getTime(), monthEnd.getTime()]);
const startPadding = getDay(monthStart); // 0=Sun
const avgPrice = useMemo(() => {
const relevantDays = pricingData.filter((d) => isSameMonth(d.date, currentMonth));
if (relevantDays.length === 0) return 200;
const total = relevantDays.reduce((sum, d) => {
const vals = Object.values(d.prices);
return sum + vals.reduce((a, b) => a + b, 0) / vals.length;
}, 0);
return total / relevantDays.length;
}, [pricingData, currentMonth]);
const getPricing = useCallback(
(date: Date): Record<string, number> | undefined => {
const found = pricingData.find((d) => isSameDay(d.date, date));
return found?.prices;
},
[pricingData],
);
const isSelected = useCallback(
(date: Date) => selectedDays.some((d) => isSameDay(d, date)),
[selectedDays],
);
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
function handleDayClick(date: Date, e: MouseEvent) {
if (e.shiftKey && shiftAnchor) {
const range = shiftAnchor < date
? eachDayOfInterval({ start: shiftAnchor, end: date })
: eachDayOfInterval({ start: date, end: shiftAnchor });
setSelectedDays((prev) => {
const existing = prev.filter((d) => !range.some((r) => isSameDay(r, d)));
return [...existing, ...range];
});
} else {
setSelectedDays((prev) => {
const already = prev.some((d) => isSameDay(d, date));
if (already) return prev.filter((d) => !isSameDay(d, date));
return [...prev, date];
});
setShiftAnchor(date);
}
}
function handlePreview() {
const price = parseFloat(newPrice);
if (isNaN(price) || price <= 0 || selectedDays.length === 0) return;
const rows: PreviewRow[] = [];
const platforms = Object.entries(selectedPlatforms)
.filter(([, checked]) => checked)
.map(([p]) => p);
for (const day of selectedDays) {
const current = getPricing(day);
for (const platform of platforms) {
const currentPrice = current?.[platform] ?? 0;
rows.push({
date: day,
platform,
currentPrice,
newPrice: price,
delta: price - currentPrice,
});
}
}
rows.sort((a, b) => a.date.getTime() - b.date.getTime());
setPreviewRows(rows);
}
function handleConfirmApply() {
if (!previewRows) return;
// Apply to pricing data
setPricingData((prev) => {
const next = [...prev];
for (const row of previewRows) {
const idx = next.findIndex((d) => isSameDay(d.date, row.date));
if (idx >= 0) {
next[idx] = {
...next[idx],
prices: { ...next[idx].prices, [row.platform]: row.newPrice },
};
}
}
return next;
});
// Add change log entries
const newRecords: PriceChangeRecord[] = previewRows.map((row, i) => ({
id: `chg-new-${Date.now()}-${i}`,
date: row.date,
platform: row.platform,
oldPrice: row.currentPrice,
newPrice: row.newPrice,
changedBy: 'admin',
note,
experiment: linkedExperiment || undefined,
changedAt: new Date(),
}));
setChangeLog((prev) => [...newRecords, ...prev]);
// Reset
setPreviewRows(null);
setSelectedDays([]);
setNewPrice('');
setNote('');
setLinkedExperiment('');
}
const filteredLog = useMemo(
() => (logPlatformFilter === 'all' ? changeLog : changeLog.filter((r) => r.platform === logPlatformFilter)),
[changeLog, logPlatformFilter],
);
const hasSelection = selectedDays.length > 0;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h1 className="text-lg font-semibold text-text-primary">Pricing</h1>
<p className="text-xs text-text-muted mt-0.5">
Select dates on the calendar, then set new prices in the panel.
</p>
</div>
{hasSelection && (
<button
onClick={() => setSelectedDays([])}
className="text-xs text-text-muted hover:text-text-primary transition-colors flex items-center gap-1"
>
<X className="w-3 h-3" />
Clear {selectedDays.length} selected
</button>
)}
</div>
{/* Main layout: calendar + sidebar */}
<div className="flex flex-col lg:flex-row gap-4">
{/* Calendar */}
<div className="flex-1 min-w-0">
{/* Month navigation */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => setCurrentMonth((m) => subMonths(m, 1))}
className="p-1.5 rounded-md hover:bg-white/5 text-text-muted hover:text-text-primary transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<h2 className="text-sm font-medium text-text-primary">
{format(currentMonth, 'MMMM yyyy')}
</h2>
<button
onClick={() => setCurrentMonth((m) => addMonths(m, 1))}
className="p-1.5 rounded-md hover:bg-white/5 text-text-muted hover:text-text-primary transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 gap-px mb-px">
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
<div key={d} className="text-center text-[10px] text-text-muted uppercase tracking-wider py-1.5">
{d}
</div>
))}
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-px">
{/* Empty cells for start padding */}
{Array.from({ length: startPadding }).map((_, i) => (
<div key={`pad-${i}`} className="bg-surface/30 rounded-md min-h-[60px] sm:min-h-[72px]" />
))}
{/* Day cells */}
{calendarDays.map((day) => {
const prices = getPricing(day);
const selected = isSelected(day);
return (
<button
key={day.toISOString()}
onClick={(e) => handleDayClick(day, e)}
className={cn(
'bg-surface border rounded-md p-1 sm:p-1.5 min-h-[60px] sm:min-h-[72px] text-left transition-all',
'hover:bg-white/5 cursor-pointer',
selected ? 'border-accent ring-1 ring-accent/40' : 'border-border',
)}
>
<span
className={cn(
'block text-[10px] sm:text-xs font-medium mb-0.5',
isWeekend(day) ? 'text-text-muted' : 'text-text-primary',
)}
>
{format(day, 'd')}
</span>
{prices && (
<div className="space-y-0.5">
{PLATFORMS.map((p) => {
const price = prices[p];
if (price == null) return null;
return (
<div key={p} className="flex items-center gap-1">
<span
className="w-1 h-1 rounded-full flex-shrink-0"
style={{ backgroundColor: PLATFORM_COLORS[p] }}
/>
<span
className={cn(
'text-[9px] sm:text-[10px] font-mono leading-none',
priceDeviationClass(price, avgPrice),
)}
>
{formatCurrency(price)}
</span>
</div>
);
})}
</div>
)}
</button>
);
})}
</div>
</div>
{/* Price Change Panel */}
<div
className={cn(
'lg:w-72 xl:w-80 flex-shrink-0 transition-all',
hasSelection ? 'opacity-100' : 'opacity-40 pointer-events-none',
)}
>
<div className="bg-surface border border-border rounded-lg p-4 space-y-4 sticky top-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-text-primary flex items-center gap-2">
<SlidersHorizontal className="w-3.5 h-3.5 text-text-muted" />
Price Change
</h3>
<span className="text-[10px] text-text-muted bg-white/5 px-1.5 py-0.5 rounded">
{selectedDays.length} day{selectedDays.length !== 1 ? 's' : ''}
</span>
</div>
{/* New price input */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
New Price (USD)
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted text-sm">$</span>
<input
type="number"
min={1}
step={1}
value={newPrice}
onChange={(e) => setNewPrice(e.target.value)}
placeholder="0"
className={cn(
'w-full rounded-md bg-background border border-border pl-7 pr-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors font-mono',
)}
/>
</div>
</div>
{/* Platform checkboxes */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1.5">
Platforms
</label>
<div className="space-y-1.5">
{PLATFORMS.map((p) => (
<label
key={p}
className="flex items-center gap-2 text-sm text-text-primary cursor-pointer"
>
<input
type="checkbox"
checked={selectedPlatforms[p] ?? false}
onChange={(e) =>
setSelectedPlatforms((prev) => ({ ...prev, [p]: e.target.checked }))
}
className="rounded border-border bg-background text-accent focus:ring-accent focus:ring-offset-0 h-3.5 w-3.5"
/>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[p] }}
/>
{PLATFORM_LABELS[p]}
</label>
))}
</div>
</div>
{/* Note */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Note (optional)
</label>
<input
type="text"
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Weekend rate bump"
className={cn(
'w-full rounded-md bg-background border border-border px-3 py-1.5 text-xs text-text-primary',
'placeholder:text-text-muted/40 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors',
)}
/>
</div>
{/* Link to experiment */}
<div>
<label className="block text-[10px] text-text-muted uppercase tracking-wider mb-1">
Link to Experiment (optional)
</label>
<select
value={linkedExperiment}
onChange={(e) => setLinkedExperiment(e.target.value)}
className={cn(
'w-full rounded-md bg-background border border-border px-3 py-1.5 text-xs text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent transition-colors',
)}
>
<option value="">None</option>
{MOCK_EXPERIMENTS.map((exp) => (
<option key={exp.id} value={exp.name}>
{exp.name}
</option>
))}
</select>
</div>
{/* Preview button */}
<button
onClick={handlePreview}
disabled={!newPrice || parseFloat(newPrice) <= 0 || !Object.values(selectedPlatforms).some(Boolean)}
className={cn(
'w-full rounded-md bg-accent text-black font-semibold py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-40 disabled:cursor-not-allowed',
)}
>
Preview Changes
</button>
</div>
</div>
</div>
{/* Preview Modal */}
{previewRows && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4">
<div className="bg-surface border border-border rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col">
{/* Modal header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="text-sm font-semibold text-text-primary">Preview Price Changes</h3>
<button
onClick={() => setPreviewRows(null)}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Warning */}
<div className="mx-5 mt-4 flex items-start gap-2 bg-warning/10 border border-warning/20 rounded-md px-3 py-2">
<AlertTriangle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
<p className="text-xs text-warning">
This will update prices on the selected platforms. Review carefully before confirming.
</p>
</div>
{/* Diff table */}
<div className="flex-1 overflow-auto px-5 py-3">
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted text-[10px] uppercase tracking-wider border-b border-border">
<th className="text-left py-2 pr-2">Date</th>
<th className="text-left py-2 pr-2">Platform</th>
<th className="text-right py-2 pr-2">Current</th>
<th className="text-right py-2 pr-2">New</th>
<th className="text-right py-2">Delta</th>
</tr>
</thead>
<tbody>
{previewRows.map((row, i) => (
<tr key={i} className="border-b border-border/50">
<td className="py-1.5 pr-2 text-text-primary font-mono">
{format(row.date, 'MMM d')}
</td>
<td className="py-1.5 pr-2">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[row.platform] }}
/>
<span className="text-text-primary">{PLATFORM_LABELS[row.platform]}</span>
</span>
</td>
<td className="py-1.5 pr-2 text-right text-text-muted font-mono">
{formatCurrency(row.currentPrice)}
</td>
<td className="py-1.5 pr-2 text-right text-text-primary font-mono">
{formatCurrency(row.newPrice)}
</td>
<td
className={cn(
'py-1.5 text-right font-mono flex items-center justify-end gap-0.5',
row.delta > 0 ? 'text-danger' : row.delta < 0 ? 'text-accent' : 'text-text-muted',
)}
>
{row.delta > 0 ? (
<ArrowUpRight className="w-3 h-3" />
) : row.delta < 0 ? (
<ArrowDownRight className="w-3 h-3" />
) : null}
{row.delta > 0 ? '+' : ''}
{formatCurrency(row.delta)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal footer */}
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-border">
<button
onClick={() => setPreviewRows(null)}
className="rounded-md border border-border px-4 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleConfirmApply}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-1.5 text-xs',
'hover:bg-accent/90 transition-colors',
)}
>
Confirm &amp; Apply
</button>
</div>
</div>
</div>
)}
{/* Price Change Log */}
<div className="bg-surface border border-border rounded-lg">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h3 className="text-sm font-medium text-text-primary">Price Change Log</h3>
<div className="flex items-center gap-2">
<Filter className="w-3 h-3 text-text-muted" />
<select
value={logPlatformFilter}
onChange={(e) => setLogPlatformFilter(e.target.value)}
className={cn(
'rounded-md bg-background border border-border px-2 py-1 text-[10px] text-text-primary',
'focus:outline-none focus:ring-1 focus:ring-accent transition-colors',
)}
>
<option value="all">All Platforms</option>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{PLATFORM_LABELS[p]}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-text-muted text-[10px] uppercase tracking-wider border-b border-border">
<th className="text-left px-4 py-2">Date</th>
<th className="text-left px-4 py-2">Platform</th>
<th className="text-right px-4 py-2">Old</th>
<th className="text-right px-4 py-2">New</th>
<th className="text-left px-4 py-2">Changed By</th>
<th className="text-left px-4 py-2">Note</th>
<th className="text-left px-4 py-2">Experiment</th>
</tr>
</thead>
<tbody>
{filteredLog.slice(0, 20).map((record) => {
const delta = record.newPrice - record.oldPrice;
return (
<tr key={record.id} className="border-b border-border/50 hover:bg-white/[0.02]">
<td className="px-4 py-2 text-text-primary font-mono">
{format(record.date, 'MMM d, yyyy')}
</td>
<td className="px-4 py-2">
<span className="flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: PLATFORM_COLORS[record.platform] }}
/>
<span className="text-text-primary">{PLATFORM_LABELS[record.platform]}</span>
</span>
</td>
<td className="px-4 py-2 text-right text-text-muted font-mono">
{formatCurrency(record.oldPrice)}
</td>
<td
className={cn(
'px-4 py-2 text-right font-mono',
delta > 0 ? 'text-danger' : delta < 0 ? 'text-accent' : 'text-text-primary',
)}
>
{formatCurrency(record.newPrice)}
</td>
<td className="px-4 py-2 text-text-muted">{record.changedBy}</td>
<td className="px-4 py-2 text-text-muted max-w-[160px] truncate">
{record.note || '\u2014'}
</td>
<td className="px-4 py-2">
{record.experiment ? (
<span className="text-accent bg-accent/10 px-1.5 py-0.5 rounded text-[10px]">
{record.experiment}
</span>
) : (
<span className="text-text-muted">{'\u2014'}</span>
)}
</td>
</tr>
);
})}
{filteredLog.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text-muted text-xs">
No price changes recorded yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,367 @@
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>
);
}

View File

@@ -0,0 +1,573 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import {
Settings as SettingsIcon,
Eye,
EyeOff,
Loader2,
CheckCircle2,
XCircle,
Send,
Wifi,
WifiOff,
Clock,
Lock,
Mail,
KeyRound,
LogIn,
} from 'lucide-react';
import { api } from '@/lib/api';
// ── Types ──────────────────────────────────────────────────────────────
type SessionStatus = 'active' | 'expired' | 'checking' | 'unknown';
type LoginStatus = 'idle' | 'logging_in' | 'success' | 'error';
// ── Helpers ─────────────────────────────────────────────────────────────
const inputClass = cn(
'w-full rounded-md bg-background border border-border px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-muted/50 focus:outline-none focus:ring-1 focus:ring-accent focus:border-accent',
'transition-colors'
);
const sectionClass = 'bg-surface border border-border rounded-lg p-6';
function SessionBadge({ status }: { status: SessionStatus }) {
if (status === 'checking') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-amber-400">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Checking...
</span>
);
}
if (status === 'active') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-green-400">
<Wifi className="w-3.5 h-3.5" />
Session Active
</span>
);
}
if (status === 'expired') {
return (
<span className="inline-flex items-center gap-1.5 text-xs text-red-400">
<WifiOff className="w-3.5 h-3.5" />
Session Expired
</span>
);
}
return (
<span className="inline-flex items-center gap-1.5 text-xs text-text-muted">
<Wifi className="w-3.5 h-3.5" />
Unknown
</span>
);
}
const PLATFORM_META: Record<string, { color: string }> = {
airbnb: { color: '#ff5a5f' },
vrbo: { color: '#3b5998' },
};
// ── Component ──────────────────────────────────────────────────────────
export default function Settings() {
const queryClient = useQueryClient();
// ── Platform data from API ──────────────────────────────────────────
const platformsQuery = useQuery({
queryKey: ['platforms'],
queryFn: () => api.getPlatforms(),
retry: false,
});
const platforms = platformsQuery.data ?? [];
// ── Session status per platform ─────────────────────────────────────
const [sessionStatuses, setSessionStatuses] = useState<Record<string, SessionStatus>>({});
const [loginStatuses, setLoginStatuses] = useState<Record<string, LoginStatus>>({});
const [loginErrors, setLoginErrors] = useState<Record<string, string>>({});
// ── Credential editing ──────────────────────────────────────────────
const [editingPlatform, setEditingPlatform] = useState<string | null>(null);
const [credEmail, setCredEmail] = useState('');
const [credPassword, setCredPassword] = useState('');
const [showCredPassword, setShowCredPassword] = useState(false);
// App password
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [passwordSaved, setPasswordSaved] = useState(false);
// Scrape schedule
const [scrapeTime, setScrapeTime] = useState('06:00');
const [scheduleSaved, setScheduleSaved] = useState(false);
// SMTP
const [smtpHost, setSmtpHost] = useState('smtp.gmail.com');
const [smtpPort, setSmtpPort] = useState('587');
const [smtpUser, setSmtpUser] = useState('');
const [smtpPassword, setSmtpPassword] = useState('');
const [showSmtpPassword, setShowSmtpPassword] = useState(false);
const [smtpTestStatus, setSmtpTestStatus] = useState<'idle' | 'sending' | 'success' | 'error'>('idle');
// ── Mutations ───────────────────────────────────────────────────────
const credentialsMutation = useMutation({
mutationFn: ({ id, credentials }: { id: string; credentials: { email: string; password: string } }) =>
api.updateCredentials(id, credentials),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['platforms'] });
setEditingPlatform(null);
setCredEmail('');
setCredPassword('');
},
});
// ── Handlers ────────────────────────────────────────────────────────
async function handleCheckSession(platformId: string) {
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'checking' }));
try {
const result = await api.checkSession(platformId);
setSessionStatuses((prev) => ({
...prev,
[platformId]: result.sessionValid ? 'active' : 'expired',
}));
} catch {
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'expired' }));
}
}
async function handleLogin(platformId: string) {
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'logging_in' }));
setLoginErrors((prev) => ({ ...prev, [platformId]: '' }));
try {
const creds = credEmail && credPassword ? { email: credEmail, password: credPassword } : undefined;
await api.loginPlatform(platformId, creds);
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'success' }));
setSessionStatuses((prev) => ({ ...prev, [platformId]: 'active' }));
setTimeout(() => setLoginStatuses((prev) => ({ ...prev, [platformId]: 'idle' })), 3000);
} catch (err: any) {
setLoginStatuses((prev) => ({ ...prev, [platformId]: 'error' }));
setLoginErrors((prev) => ({ ...prev, [platformId]: err.message || 'Login failed' }));
}
}
function handleUpdateCredentials(platformId: string) {
if (!credEmail || !credPassword) return;
credentialsMutation.mutate({ id: platformId, credentials: { email: credEmail, password: credPassword } });
}
function startEditingPlatform(platform: any) {
setEditingPlatform(platform.id);
setCredEmail('');
setCredPassword('');
setShowCredPassword(false);
}
function handleSavePassword() {
if (!newPassword || newPassword !== confirmPassword) return;
setPasswordSaved(true);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setPasswordSaved(false), 3000);
}
function handleSaveSchedule() {
setScheduleSaved(true);
setTimeout(() => setScheduleSaved(false), 3000);
}
function handleTestEmail() {
setSmtpTestStatus('sending');
setTimeout(() => {
setSmtpTestStatus('success');
setTimeout(() => setSmtpTestStatus('idle'), 3000);
}, 2000);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<SettingsIcon className="w-5 h-5 text-accent" />
<h1 className="text-lg font-semibold text-text-primary">Settings</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* ─── Platform Credentials & Session ─── */}
<div className={cn(sectionClass, 'lg:col-span-2')}>
<div className="flex items-center gap-2 mb-5">
<KeyRound className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
Platform Connections
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{platforms.map((platform: any) => {
const meta = PLATFORM_META[platform.id] || { color: '#888' };
const session = sessionStatuses[platform.id] || 'unknown';
const loginStatus = loginStatuses[platform.id] || 'idle';
const loginError = loginErrors[platform.id] || '';
return (
<div
key={platform.id}
className="bg-background border border-border rounded-lg p-4 space-y-3"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: meta.color }}
/>
<span className="font-medium text-text-primary">{platform.displayName}</span>
</div>
<SessionBadge status={session} />
</div>
<div className="text-xs text-text-muted">
Credentials: {platform.hasCredentials ? (
<span className="text-green-400">Saved</span>
) : (
<span className="text-amber-400">Not configured</span>
)}
</div>
{editingPlatform === platform.id ? (
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Email</label>
<input
type="email"
value={credEmail}
onChange={(e) => setCredEmail(e.target.value)}
className={inputClass}
placeholder="Airbnb email address"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Password</label>
<div className="relative">
<input
type={showCredPassword ? 'text' : 'password'}
value={credPassword}
onChange={(e) => setCredPassword(e.target.value)}
className={cn(inputClass, 'pr-10')}
placeholder="Platform password"
/>
<button
type="button"
onClick={() => setShowCredPassword(!showCredPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showCredPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleUpdateCredentials(platform.id)}
disabled={credentialsMutation.isPending}
className={cn(
'rounded-md bg-accent text-black font-semibold px-3 py-1.5 text-xs',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{credentialsMutation.isPending ? 'Saving...' : 'Save Credentials'}
</button>
<button
onClick={() => handleLogin(platform.id)}
disabled={loginStatus === 'logging_in' || !credEmail || !credPassword}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-accent/50 px-3 py-1.5 text-xs text-accent',
'hover:bg-accent/10 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loginStatus === 'logging_in' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogIn className="w-3 h-3" />
)}
{loginStatus === 'logging_in' ? 'Logging in...' : 'Save & Login'}
</button>
<button
onClick={() => setEditingPlatform(null)}
className="rounded-md px-3 py-1.5 text-xs text-text-muted hover:text-text-primary transition-colors"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
onClick={() => startEditingPlatform(platform)}
className={cn(
'rounded-md border border-border px-3 py-1.5 text-xs text-text-primary',
'hover:bg-white/[0.04] transition-colors'
)}
>
{platform.hasCredentials ? 'Update Credentials' : 'Set Credentials'}
</button>
<button
onClick={() => handleCheckSession(platform.id)}
disabled={session === 'checking'}
className={cn(
'rounded-md border border-border px-3 py-1.5 text-xs text-text-primary',
'hover:bg-white/[0.04] transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Check Session
</button>
<button
onClick={() => handleLogin(platform.id)}
disabled={loginStatus === 'logging_in'}
className={cn(
'inline-flex items-center gap-1.5 rounded-md border border-accent/50 px-3 py-1.5 text-xs text-accent',
'hover:bg-accent/10 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{loginStatus === 'logging_in' ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LogIn className="w-3 h-3" />
)}
{loginStatus === 'logging_in' ? 'Logging in...' : 'Login'}
</button>
</div>
)}
{/* Login feedback */}
{loginStatus === 'success' && (
<div className="flex items-center gap-1.5 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Login successful session active
</div>
)}
{loginStatus === 'error' && (
<div className="flex items-center gap-1.5 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
{loginError || 'Login failed'}
</div>
)}
{loginStatus === 'logging_in' && (
<div className="text-xs text-amber-400">
A browser window will open. Complete MFA if prompted, then wait...
</div>
)}
</div>
);
})}
</div>
</div>
{/* ─── App Password ─── */}
<div className={sectionClass}>
<div className="flex items-center gap-2 mb-5">
<Lock className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
App Password
</h2>
</div>
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Current Password</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className={inputClass}
placeholder="Current password"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">New Password</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className={inputClass}
placeholder="New password"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Confirm Password</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className={inputClass}
placeholder="Confirm new password"
/>
{confirmPassword && newPassword !== confirmPassword && (
<p className="text-xs text-red-400 mt-1">Passwords do not match</p>
)}
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={handleSavePassword}
disabled={!currentPassword || !newPassword || newPassword !== confirmPassword}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
Update Password
</button>
{passwordSaved && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Saved
</span>
)}
</div>
</div>
</div>
{/* ─── Scrape Schedule ─── */}
<div className={sectionClass}>
<div className="flex items-center gap-2 mb-5">
<Clock className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
Scrape Schedule
</h2>
</div>
<p className="text-sm text-text-muted mb-4">
Set the daily time for automatic platform data scraping. All times are in your local timezone.
</p>
<div className="space-y-3">
<div>
<label className="block text-xs text-text-muted mb-1">Daily Scrape Time</label>
<input
type="time"
value={scrapeTime}
onChange={(e) => setScrapeTime(e.target.value)}
className={cn(inputClass, 'max-w-[200px]')}
/>
</div>
<div className="flex items-center gap-3 pt-1">
<button
onClick={handleSaveSchedule}
className={cn(
'rounded-md bg-accent text-black font-semibold px-4 py-2 text-sm',
'hover:bg-accent/90 transition-colors'
)}
>
Save Schedule
</button>
{scheduleSaved && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Saved
</span>
)}
</div>
</div>
</div>
{/* ─── SMTP Configuration ─── */}
<div className={cn(sectionClass, 'lg:col-span-2')}>
<div className="flex items-center gap-2 mb-5">
<Mail className="w-4 h-4 text-text-muted" />
<h2 className="text-sm font-semibold text-text-primary uppercase tracking-wide">
SMTP Configuration
</h2>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-text-muted mb-1">SMTP Host</label>
<input
type="text"
value={smtpHost}
onChange={(e) => setSmtpHost(e.target.value)}
className={inputClass}
placeholder="smtp.gmail.com"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Port</label>
<input
type="text"
value={smtpPort}
onChange={(e) => setSmtpPort(e.target.value)}
className={inputClass}
placeholder="587"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Username</label>
<input
type="text"
value={smtpUser}
onChange={(e) => setSmtpUser(e.target.value)}
className={inputClass}
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-xs text-text-muted mb-1">Password</label>
<div className="relative">
<input
type={showSmtpPassword ? 'text' : 'password'}
value={smtpPassword}
onChange={(e) => setSmtpPassword(e.target.value)}
className={cn(inputClass, 'pr-10')}
placeholder="App password"
/>
<button
type="button"
onClick={() => setShowSmtpPassword(!showSmtpPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary"
>
{showSmtpPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
</div>
<div className="flex items-center gap-3 mt-4">
<button
onClick={handleTestEmail}
disabled={smtpTestStatus === 'sending'}
className={cn(
'inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm text-text-primary',
'hover:bg-white/[0.04] transition-colors',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{smtpTestStatus === 'sending' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Send Test Email
</button>
{smtpTestStatus === 'success' && (
<span className="inline-flex items-center gap-1 text-xs text-green-400">
<CheckCircle2 className="w-3.5 h-3.5" />
Test email sent
</span>
)}
{smtpTestStatus === 'error' && (
<span className="inline-flex items-center gap-1 text-xs text-red-400">
<XCircle className="w-3.5 h-3.5" />
Failed to send
</span>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss';
const config: Config = {
darkMode: 'class',
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
background: '#0a0a0a',
surface: '#141414',
border: '#262626',
accent: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
'text-primary': '#fafafa',
'text-muted': '#737373',
},
fontFamily: {
mono: ['JetBrains Mono', 'IBM Plex Mono', 'monospace'],
sans: ['DM Sans', 'Geist', 'system-ui', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});