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:
15
apps/frontend/index.html
Normal file
15
apps/frontend/index.html
Normal 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>
|
||||
41
apps/frontend/package.json
Normal file
41
apps/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/frontend/postcss.config.js
Normal file
6
apps/frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
42
apps/frontend/src/App.tsx
Normal file
42
apps/frontend/src/App.tsx
Normal 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;
|
||||
138
apps/frontend/src/components/layout/Shell.tsx
Normal file
138
apps/frontend/src/components/layout/Shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/frontend/src/hooks/useAuth.ts
Normal file
47
apps/frontend/src/hooks/useAuth.ts
Normal 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 };
|
||||
}
|
||||
62
apps/frontend/src/index.css
Normal file
62
apps/frontend/src/index.css
Normal 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);
|
||||
}
|
||||
119
apps/frontend/src/lib/api.ts
Normal file
119
apps/frontend/src/lib/api.ts
Normal 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 };
|
||||
27
apps/frontend/src/lib/constants.ts
Normal file
27
apps/frontend/src/lib/constants.ts
Normal 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',
|
||||
};
|
||||
52
apps/frontend/src/lib/utils.ts
Normal file
52
apps/frontend/src/lib/utils.ts
Normal 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`;
|
||||
}
|
||||
25
apps/frontend/src/main.tsx
Normal file
25
apps/frontend/src/main.tsx
Normal 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>,
|
||||
);
|
||||
306
apps/frontend/src/pages/Dashboard.tsx
Normal file
306
apps/frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
530
apps/frontend/src/pages/Experiments.tsx
Normal file
530
apps/frontend/src/pages/Experiments.tsx
Normal 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)} – {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">→</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>
|
||||
);
|
||||
}
|
||||
105
apps/frontend/src/pages/Login.tsx
Normal file
105
apps/frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
602
apps/frontend/src/pages/Performance.tsx
Normal file
602
apps/frontend/src/pages/Performance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
714
apps/frontend/src/pages/Pricing.tsx
Normal file
714
apps/frontend/src/pages/Pricing.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
367
apps/frontend/src/pages/Reservations.tsx
Normal file
367
apps/frontend/src/pages/Reservations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
573
apps/frontend/src/pages/Settings.tsx
Normal file
573
apps/frontend/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
apps/frontend/tailwind.config.ts
Normal file
27
apps/frontend/tailwind.config.ts
Normal 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;
|
||||
19
apps/frontend/tsconfig.json
Normal file
19
apps/frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
21
apps/frontend/vite.config.ts
Normal file
21
apps/frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user