Add Rate Monitor app — Booking.com + direct booking engine competitor rates
Combines Booking.com Playwright scraper (from forecasting), direct booking engine scraper (ported from laptop-archive/guestline-monitor), and Newbook own-hotel rates into one focused tool. Four views: Bookability, Market View (with price index badges + direct rate sub-rows), Direct Rates (per-competitor room breakdown, min-stay flags, hotel config/discovery), Rate Analysis (advance purchase curve, DOW chart, rate timeline, comparison table). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
e05054172f
50 changed files with 11860 additions and 0 deletions
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import AuthGate from './components/AuthGate'
|
||||
import Layout from './components/Layout'
|
||||
import Bookability from './pages/Bookability'
|
||||
import MarketView from './pages/MarketView'
|
||||
import DirectRates from './pages/DirectRates'
|
||||
import RateAnalysis from './pages/RateAnalysis'
|
||||
import Settings from './pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/bookability" replace />} />
|
||||
<Route path="/bookability" element={<Bookability />} />
|
||||
<Route path="/market" element={<MarketView />} />
|
||||
<Route path="/direct" element={<DirectRates />} />
|
||||
<Route path="/direct/:hotelId" element={<DirectRates />} />
|
||||
<Route path="/analysis" element={<RateAnalysis />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/settings/:tab" element={<Settings />} />
|
||||
<Route path="*" element={<Navigate to="/bookability" replace />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
)
|
||||
}
|
||||
20
frontend/src/api.ts
Normal file
20
frontend/src/api.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import axios from 'axios'
|
||||
|
||||
const BASE = '/rates/api'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
47
frontend/src/components/AuthGate.tsx
Normal file
47
frontend/src/components/AuthGate.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { User } from '../types'
|
||||
|
||||
interface AuthCtx { user: User }
|
||||
const Ctx = createContext<AuthCtx | null>(null)
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(Ctx)
|
||||
if (!ctx) throw new Error('useAuth outside AuthGate')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export default function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null)
|
||||
const [checking, setChecking] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/rates/api/auth/verify?app=rates', { credentials: 'include' })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('unauth')
|
||||
return r.json()
|
||||
})
|
||||
.then(data => setUser({
|
||||
email: data.email || data.sub || '',
|
||||
name: data.name || data.display_name || '',
|
||||
is_admin: data.is_admin ?? false,
|
||||
caps: data.caps ?? [],
|
||||
}))
|
||||
.catch(() => {
|
||||
window.location.href = '/auth/login?redirect=' + encodeURIComponent(window.location.pathname)
|
||||
})
|
||||
.finally(() => setChecking(false))
|
||||
}, [])
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--navy-dark)' }}>
|
||||
<div className="spinner" style={{ width: 32, height: 32 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return <Ctx.Provider value={{ user }}>{children}</Ctx.Provider>
|
||||
}
|
||||
70
frontend/src/components/Layout.tsx
Normal file
70
frontend/src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TrendingUp, Calendar, Globe, BarChart2, Settings, Building2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from './AuthGate'
|
||||
import { can } from '../types'
|
||||
import api from '../api'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
const ICON = { size: 16, strokeWidth: 1.75 }
|
||||
|
||||
const NAV = [
|
||||
{ to: '/bookability', label: 'Bookability', icon: Calendar, cap: 'view_own_rates' },
|
||||
{ to: '/market', label: 'Market View', icon: Globe, cap: 'view_competitors' },
|
||||
{ to: '/direct', label: 'Direct Rates', icon: Building2, cap: 'view_direct_rates' },
|
||||
{ to: '/analysis', label: 'Rate Analysis', icon: TrendingUp, cap: 'rate_analysis' },
|
||||
{ to: '/settings', label: 'Settings', icon: Settings, cap: 'manage_scraper' },
|
||||
]
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
const { user } = useAuth()
|
||||
const items = NAV.filter(n => can(user, n.cap))
|
||||
|
||||
const { data: alertCount } = useQuery<number>({
|
||||
queryKey: ['parity-alert-count'],
|
||||
queryFn: () => api.get('/competitors/parity/alerts?status=active').then(r => r.data.length),
|
||||
refetchInterval: 60_000,
|
||||
enabled: can(user, 'view_competitors'),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-logo">
|
||||
<BarChart2 size={18} strokeWidth={1.75} />
|
||||
Rate Monitor
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
{items.map(({ to, label, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
<Icon {...ICON} />
|
||||
{label}
|
||||
{to === '/market' && alertCount ? (
|
||||
<span style={{
|
||||
marginLeft: 'auto', background: 'var(--danger)', color: '#fff',
|
||||
borderRadius: 10, fontSize: 10, fontWeight: 700,
|
||||
padding: '1px 6px', lineHeight: '16px',
|
||||
}}>{alertCount}</span>
|
||||
) : null}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="sidebar-user">{user.name || user.email}</div>
|
||||
</aside>
|
||||
|
||||
<header className="top-bar">
|
||||
<BarChart2 size={18} strokeWidth={1.75} color="var(--gold)" />
|
||||
<span className="top-bar-title">Rate Monitor</span>
|
||||
<nav className="top-bar-nav">
|
||||
{items.map(({ to, label }) => (
|
||||
<NavLink key={to} to={to} className={({ isActive }) => isActive ? 'active' : ''}>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="page-content">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
363
frontend/src/index.css
Normal file
363
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
:root {
|
||||
--navy: #1a1a2e;
|
||||
--navy-dark: #0f0f20;
|
||||
--gold: #c9a84c;
|
||||
--gold-light: #e8c96d;
|
||||
--surface: rgba(255,255,255,0.07);
|
||||
--surface-2: rgba(255,255,255,0.08);
|
||||
--text: rgba(255,255,255,0.88);
|
||||
--text-muted: rgba(255,255,255,0.48);
|
||||
--body-bg: #f4f5f7;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e4e8ee;
|
||||
--text-dark: #1e293b;
|
||||
--text-mid: #64748b;
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.07), 0 1px 2px rgba(0,0,0,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--radius: 10px;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--body-bg);
|
||||
color: var(--text-dark);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* App shell layout */
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 220px 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / -1;
|
||||
background: var(--navy);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 20px 16px 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--surface);
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.sidebar-nav a:hover { background: var(--surface); color: var(--text); }
|
||||
.sidebar-nav a.active { background: rgba(201,168,76,0.15); color: var(--gold); }
|
||||
|
||||
.sidebar-user {
|
||||
padding: 12px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--surface);
|
||||
}
|
||||
|
||||
/* Top bar — mobile/collapsed fallback */
|
||||
.top-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / -1;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dark);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-body { padding: 20px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--gold);
|
||||
color: var(--navy);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--gold-light); }
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--navy);
|
||||
color: var(--text);
|
||||
}
|
||||
.btn-secondary:hover:not(:disabled) { background: var(--navy-dark); }
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--text-dark);
|
||||
border: 1px solid var(--card-border);
|
||||
}
|
||||
.btn-outline:hover:not(:disabled) { background: var(--body-bg); }
|
||||
|
||||
.btn-danger {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { opacity: 0.85; }
|
||||
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* Form elements */
|
||||
input, select, textarea {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: 7px;
|
||||
padding: 7px 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-dark);
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--gold); }
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.badge-success { background: #dcfce7; color: #16a34a; }
|
||||
.badge-warning { background: #fef3c7; color: #d97706; }
|
||||
.badge-danger { background: #fee2e2; color: #dc2626; }
|
||||
.badge-info { background: #dbeafe; color: #2563eb; }
|
||||
.badge-neutral { background: #f1f5f9; color: #64748b; }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
font-weight: 600;
|
||||
color: var(--text-mid);
|
||||
white-space: nowrap;
|
||||
}
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: #f8fafc; }
|
||||
|
||||
/* Page header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
.page-subtitle { font-size: 13px; color: var(--text-mid); margin-top: 2px; }
|
||||
|
||||
/* Sub-nav tabs */
|
||||
.sub-nav {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
margin-bottom: 24px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.sub-nav-item {
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-mid);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
white-space: nowrap;
|
||||
background: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: none;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.sub-nav-item:hover { color: var(--text-dark); }
|
||||
.sub-nav-item.active { color: var(--gold); border-bottom-color: var(--gold); }
|
||||
|
||||
/* Status dot */
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.green { background: var(--success); }
|
||||
.status-dot.yellow { background: var(--warning); }
|
||||
.status-dot.red { background: var(--danger); }
|
||||
.status-dot.grey { background: #94a3b8; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 20px; height: 20px;
|
||||
border: 2px solid var(--card-border);
|
||||
border-top-color: var(--gold);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
display: inline-block;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Loading / empty states */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: var(--text-mid);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Grid helpers */
|
||||
.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
|
||||
.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }
|
||||
|
||||
/* Stat card */
|
||||
.stat-card {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 20px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-label { font-size: 12px; color: var(--text-mid); margin-bottom: 4px; }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: var(--text-dark); }
|
||||
.stat-delta { font-size: 12px; margin-top: 4px; }
|
||||
.stat-delta.positive { color: var(--success); }
|
||||
.stat-delta.negative { color: var(--danger); }
|
||||
.stat-delta.neutral { color: var(--text-mid); }
|
||||
|
||||
/* Responsive — at narrow widths hide sidebar, show top-bar */
|
||||
@media (max-width: 900px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
.sidebar { display: none; }
|
||||
.top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--navy);
|
||||
padding: 0 16px;
|
||||
height: 52px;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.top-bar-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.top-bar-nav { display: flex; gap: 4px; }
|
||||
.top-bar-nav a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 12.5px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.top-bar-nav a:hover { color: var(--text); background: var(--surface); }
|
||||
.top-bar-nav a.active { color: var(--gold); }
|
||||
.page-content {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
20
frontend/src/main.tsx
Normal file
20
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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 qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 1, staleTime: 30_000 } },
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename="/rates">
|
||||
<QueryClientProvider client={qc}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
1094
frontend/src/pages/Bookability.tsx
Normal file
1094
frontend/src/pages/Bookability.tsx
Normal file
File diff suppressed because it is too large
Load diff
520
frontend/src/pages/DirectRates.tsx
Normal file
520
frontend/src/pages/DirectRates.tsx
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
import React, { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Building2, RefreshCw, Plus, Settings, ChevronDown, ChevronRight,
|
||||
AlertCircle, CheckCircle, Clock,
|
||||
} from 'lucide-react'
|
||||
import api from '../api'
|
||||
import { useAuth } from '../components/AuthGate'
|
||||
import { can } from '../types'
|
||||
|
||||
const fmtDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
||||
const TABS = ['overview', 'hotel', 'manage'] as const
|
||||
type Tab = typeof TABS[number]
|
||||
|
||||
interface DirectHotel {
|
||||
id: number
|
||||
name: string
|
||||
profile_name: string
|
||||
scrape_enabled: boolean
|
||||
last_scraped_at: string | null
|
||||
scraped_dates: number
|
||||
last_rate_at: string | null
|
||||
}
|
||||
|
||||
interface DateRow {
|
||||
stay_date: string
|
||||
cheapest_rate: number | null
|
||||
has_availability: boolean
|
||||
has_min_stay: boolean
|
||||
scraped_at: string | null
|
||||
}
|
||||
|
||||
interface RoomRow {
|
||||
room_id: string
|
||||
rate_id: string
|
||||
room_label: string
|
||||
rate_label: string
|
||||
availability: number
|
||||
price_incl: number | null
|
||||
min_stay_nights: number | null
|
||||
bench_rate: number | null
|
||||
}
|
||||
|
||||
const fmt = (v: number | null) => v != null ? `£${v.toFixed(2)}` : '—'
|
||||
const age = (ts: string | null) => {
|
||||
if (!ts) return 'Never'
|
||||
const h = Math.round((Date.now() - new Date(ts).getTime()) / 3600000)
|
||||
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`
|
||||
}
|
||||
|
||||
export default function DirectRates() {
|
||||
const { hotelId } = useParams<{ hotelId?: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const canManage = can(user, 'manage_hotels')
|
||||
|
||||
const [tab, setTab] = useState<Tab>(hotelId ? 'hotel' : 'overview')
|
||||
const [selectedHotel, setSelectedHotel] = useState<number | null>(hotelId ? parseInt(hotelId) : null)
|
||||
const [expandedDate, setExpandedDate] = useState<string | null>(null)
|
||||
const [fromDate, setFromDate] = useState(fmtDate(new Date()))
|
||||
const [toDate, setToDate] = useState(fmtDate(new Date(Date.now() + 89 * 86400000)))
|
||||
|
||||
const { data: hotels, isLoading: hotelsLoading } = useQuery<DirectHotel[]>({
|
||||
queryKey: ['direct-hotels'],
|
||||
queryFn: () => api.get('/direct/hotels').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: dates, isLoading: datesLoading } = useQuery<{ dates: DateRow[]; hotel_name: string }>({
|
||||
queryKey: ['direct-dates', selectedHotel, fromDate, toDate],
|
||||
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/dates`, {
|
||||
params: { from_date: fromDate, to_date: toDate }
|
||||
}).then(r => r.data),
|
||||
enabled: !!selectedHotel && tab === 'hotel',
|
||||
})
|
||||
|
||||
const { data: roomData } = useQuery<{ rooms: RoomRow[]; bench_price: number | null }>({
|
||||
queryKey: ['direct-rooms', selectedHotel, expandedDate],
|
||||
queryFn: () => api.get(`/direct/hotels/${selectedHotel}/date/${expandedDate}/rooms`).then(r => r.data),
|
||||
enabled: !!selectedHotel && !!expandedDate,
|
||||
})
|
||||
|
||||
const qc = useQueryClient()
|
||||
const scrapeMutation = useMutation({
|
||||
mutationFn: (id: number) => api.post(`/direct/hotels/${id}/scrape`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['direct-hotels'] }),
|
||||
})
|
||||
|
||||
const selectHotel = (id: number) => {
|
||||
setSelectedHotel(id)
|
||||
setTab('hotel')
|
||||
setExpandedDate(null)
|
||||
navigate(`/direct/${id}`)
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ label: '7d', days: 7 }, { label: '14d', days: 14 },
|
||||
{ label: '30d', days: 30 }, { label: '90d', days: 90 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Direct Rates</div>
|
||||
<div className="page-subtitle">Competitor booking engine rates — scraped directly</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sub-nav">
|
||||
{TABS.map(t => (
|
||||
<button key={t} className={`sub-nav-item${tab === t ? ' active' : ''}`}
|
||||
onClick={() => setTab(t)}>
|
||||
{t === 'overview' ? 'Overview' : t === 'hotel' ? 'Hotel Detail' : 'Manage'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'overview' && (
|
||||
<OverviewTab
|
||||
hotels={hotels || []}
|
||||
loading={hotelsLoading}
|
||||
onSelectHotel={selectHotel}
|
||||
onScrape={id => scrapeMutation.mutate(id)}
|
||||
scraping={scrapeMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'hotel' && (
|
||||
<HotelDetailTab
|
||||
hotels={hotels || []}
|
||||
selectedHotel={selectedHotel}
|
||||
onSelectHotel={selectHotel}
|
||||
dates={dates?.dates || []}
|
||||
hotelName={dates?.hotel_name}
|
||||
datesLoading={datesLoading}
|
||||
fromDate={fromDate}
|
||||
toDate={toDate}
|
||||
setFromDate={setFromDate}
|
||||
setToDate={setToDate}
|
||||
presets={presets}
|
||||
expandedDate={expandedDate}
|
||||
setExpandedDate={setExpandedDate}
|
||||
roomData={roomData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === 'manage' && canManage && (
|
||||
<ManageTab hotels={hotels || []} onRefresh={() => qc.invalidateQueries({ queryKey: ['direct-hotels'] })} />
|
||||
)}
|
||||
{tab === 'manage' && !canManage && (
|
||||
<div className="empty-state">You don't have permission to manage competitor hotels.</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Overview Tab ─────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ hotels, loading, onSelectHotel, onScrape, scraping }: {
|
||||
hotels: DirectHotel[]
|
||||
loading: boolean
|
||||
onSelectHotel: (id: number) => void
|
||||
onScrape: (id: number) => void
|
||||
scraping: boolean
|
||||
}) {
|
||||
if (loading) return <div className="loading-state"><div className="spinner" />Loading…</div>
|
||||
if (!hotels.length) return (
|
||||
<div className="empty-state">
|
||||
<Building2 size={32} strokeWidth={1.5} style={{ margin: '0 auto 12px', display: 'block', color: 'var(--text-mid)' }} />
|
||||
No competitor hotels configured. Use the Manage tab to add hotels.
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hotel</th>
|
||||
<th>Engine</th>
|
||||
<th>Dates Scraped</th>
|
||||
<th>Last Scrape</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hotels.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td>
|
||||
<button
|
||||
onClick={() => onSelectHotel(h.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontWeight: 600, padding: 0, fontSize: 13 }}
|
||||
>
|
||||
{h.name}
|
||||
</button>
|
||||
</td>
|
||||
<td><span className="badge badge-neutral">{h.profile_name}</span></td>
|
||||
<td>{h.scraped_dates}</td>
|
||||
<td style={{ color: 'var(--text-mid)', fontSize: 12 }}>{age(h.last_scraped_at)}</td>
|
||||
<td>
|
||||
<span className={`badge ${h.scrape_enabled ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{h.scrape_enabled ? 'Active' : 'Paused'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onScrape(h.id)} disabled={scraping}>
|
||||
<RefreshCw size={12} strokeWidth={1.75} />
|
||||
Scrape
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Hotel Detail Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
function HotelDetailTab({ hotels, selectedHotel, onSelectHotel, dates, hotelName, datesLoading,
|
||||
fromDate, toDate, setFromDate, setToDate, presets, expandedDate, setExpandedDate, roomData }: any) {
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* Controls */}
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>Hotel</label>
|
||||
<select
|
||||
style={{ width: 220 }}
|
||||
value={selectedHotel || ''}
|
||||
onChange={e => onSelectHotel(parseInt(e.target.value))}
|
||||
>
|
||||
<option value="">Select a hotel…</option>
|
||||
{hotels.map((h: DirectHotel) => (
|
||||
<option key={h.id} value={h.id}>{h.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>From</label>
|
||||
<input type="date" style={{ width: 140 }} value={fromDate} onChange={e => setFromDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>To</label>
|
||||
<input type="date" style={{ width: 140 }} value={toDate} onChange={e => setToDate(e.target.value)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{presets.map((p: any) => (
|
||||
<button key={p.label} className="btn btn-outline btn-sm"
|
||||
onClick={() => {
|
||||
const from = new Date(); const to = new Date(Date.now() + p.days * 86400000)
|
||||
setFromDate(fmtDate(from)); setToDate(fmtDate(to))
|
||||
}}>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedHotel && <div className="empty-state">Select a hotel to view rates.</div>}
|
||||
|
||||
{selectedHotel && datesLoading && <div className="loading-state"><div className="spinner" />Loading…</div>}
|
||||
|
||||
{selectedHotel && !datesLoading && dates.length === 0 && (
|
||||
<div className="empty-state">No rate data for this period. Run a scrape first.</div>
|
||||
)}
|
||||
|
||||
{selectedHotel && !datesLoading && dates.length > 0 && (
|
||||
<div className="card">
|
||||
<div className="card-header">{hotelName} — {dates.length} dates</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Date</th>
|
||||
<th>Cheapest Rate</th>
|
||||
<th>Availability</th>
|
||||
<th>Min-Stay</th>
|
||||
<th>Scraped</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dates.map((d: DateRow) => (
|
||||
<React.Fragment key={d.stay_date}>
|
||||
<tr
|
||||
onClick={() => setExpandedDate(expandedDate === d.stay_date ? null : d.stay_date)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td style={{ width: 24, color: 'var(--text-mid)' }}>
|
||||
{expandedDate === d.stay_date
|
||||
? <ChevronDown size={14} strokeWidth={1.75} />
|
||||
: <ChevronRight size={14} strokeWidth={1.75} />}
|
||||
</td>
|
||||
<td>{d.stay_date}</td>
|
||||
<td style={{ fontWeight: 600 }}>
|
||||
{d.cheapest_rate ? `£${Number(d.cheapest_rate).toFixed(2)}` : '—'}
|
||||
</td>
|
||||
<td>
|
||||
{d.has_availability
|
||||
? <CheckCircle size={14} strokeWidth={1.75} color="var(--success)" />
|
||||
: <AlertCircle size={14} strokeWidth={1.75} color="var(--danger)" />}
|
||||
</td>
|
||||
<td>
|
||||
{d.has_min_stay
|
||||
? <span className="badge badge-warning">Min-stay</span>
|
||||
: null}
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text-mid)' }}>{age(d.scraped_at)}</td>
|
||||
</tr>
|
||||
{expandedDate === d.stay_date && roomData && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ background: '#f8fafc', padding: '8px 16px' }}>
|
||||
<RoomBreakdown rooms={roomData.rooms} benchPrice={roomData.bench_price} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RoomBreakdown({ rooms, benchPrice }: { rooms: RoomRow[]; benchPrice: number | null }) {
|
||||
return (
|
||||
<table style={{ width: '100%', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Room</th>
|
||||
<th>Rate Plan</th>
|
||||
<th>Avail</th>
|
||||
<th>Price</th>
|
||||
<th>Bench Rate</th>
|
||||
<th>Min-Stay</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rooms.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.room_label}</td>
|
||||
<td style={{ color: 'var(--text-mid)' }}>{r.rate_label}</td>
|
||||
<td>{r.availability > 0 ? <CheckCircle size={12} strokeWidth={1.75} color="var(--success)" /> : <AlertCircle size={12} strokeWidth={1.75} color="var(--danger)" />}</td>
|
||||
<td style={{ fontWeight: 600 }}>{fmt(r.price_incl)}</td>
|
||||
<td style={{ color: 'var(--text-mid)' }}>{fmt(r.bench_rate)}</td>
|
||||
<td>{r.min_stay_nights && r.min_stay_nights > 1 ? <span className="badge badge-warning">{r.min_stay_nights}N</span> : null}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Manage Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ManageTab({ hotels, onRefresh }: { hotels: DirectHotel[]; onRefresh: () => void }) {
|
||||
const [showAdd, setShowAdd] = useState(false)
|
||||
const [detectUrl, setDetectUrl] = useState('')
|
||||
const [detected, setDetected] = useState<any>(null)
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [extraParams, setExtraParams] = useState<Record<string, string>>({})
|
||||
const [profiles, setProfiles] = useState<any[]>([])
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data: profileList } = useQuery({
|
||||
queryKey: ['direct-profiles'],
|
||||
queryFn: () => api.get('/direct/profiles').then(r => r.data),
|
||||
})
|
||||
|
||||
const detectMutation = useMutation({
|
||||
mutationFn: (url: string) => api.post('/direct/profiles/detect', { url }),
|
||||
onSuccess: (res) => setDetected(res.data),
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (body: any) => api.post('/direct/hotels', body),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['direct-hotels'] }); setShowAdd(false); setDetected(null); setDetectUrl('') },
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
api.put(`/direct/hotels/${id}`, { scrape_enabled: enabled }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['direct-hotels'] }),
|
||||
})
|
||||
|
||||
const discoveryMutation = useMutation({
|
||||
mutationFn: (id: number) => api.post(`/direct/hotels/${id}/discover`),
|
||||
})
|
||||
|
||||
const requiredParams = detected
|
||||
? (profileList || []).find((p: any) => p.name === detected.profile)?.required_params || []
|
||||
: []
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-primary" onClick={() => setShowAdd(!showAdd)}>
|
||||
<Plus size={14} strokeWidth={1.75} />
|
||||
Add Competitor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAdd && (
|
||||
<div className="card">
|
||||
<div className="card-header">Add Competitor Hotel</div>
|
||||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>
|
||||
Booking URL (paste any booking page URL to auto-detect engine)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input value={detectUrl} onChange={e => setDetectUrl(e.target.value)}
|
||||
placeholder="https://booking.eu.guestline.app/..." />
|
||||
<button className="btn btn-outline"
|
||||
onClick={() => detectMutation.mutate(detectUrl)}
|
||||
disabled={!detectUrl || detectMutation.isPending}>
|
||||
Detect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detected && (
|
||||
<>
|
||||
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0', fontSize: 13 }}>
|
||||
<strong>Detected:</strong> {detected.profile} engine
|
||||
{Object.entries(detected).filter(([k]) => k !== 'profile').map(([k, v]) => (
|
||||
<span key={k} style={{ marginLeft: 12, color: 'var(--text-mid)' }}>{k}: <strong>{String(v)}</strong></span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>Hotel Name</label>
|
||||
<input value={newName} onChange={e => setNewName(e.target.value)} placeholder="e.g. Three Ways House Hotel" style={{ maxWidth: 300 }} />
|
||||
</div>
|
||||
|
||||
{requiredParams.filter((p: any) => !(p.key in detected)).map((p: any) => (
|
||||
<div key={p.key}>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>{p.label}</label>
|
||||
<input
|
||||
placeholder={p.help}
|
||||
style={{ maxWidth: 300 }}
|
||||
value={extraParams[p.key] || ''}
|
||||
onChange={e => setExtraParams(prev => ({ ...prev, [p.key]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => createMutation.mutate({
|
||||
name: newName,
|
||||
profile_name: detected.profile,
|
||||
params: { ...detected, ...extraParams, profile: undefined },
|
||||
})}
|
||||
disabled={!newName || createMutation.isPending}
|
||||
>
|
||||
Add Hotel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">Configured Competitors</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Engine</th>
|
||||
<th>Scraping</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hotels.length === 0 && (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--text-mid)', padding: 24 }}>No competitors configured yet.</td></tr>
|
||||
)}
|
||||
{hotels.map(h => (
|
||||
<tr key={h.id}>
|
||||
<td style={{ fontWeight: 500 }}>{h.name}</td>
|
||||
<td><span className="badge badge-neutral">{h.profile_name}</span></td>
|
||||
<td>
|
||||
<button
|
||||
className={`btn btn-sm ${h.scrape_enabled ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => toggleMutation.mutate({ id: h.id, enabled: !h.scrape_enabled })}
|
||||
>
|
||||
{h.scrape_enabled ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ display: 'flex', gap: 6 }}>
|
||||
<button className="btn btn-outline btn-sm"
|
||||
onClick={() => discoveryMutation.mutate(h.id)}
|
||||
disabled={discoveryMutation.isPending}>
|
||||
<Clock size={12} strokeWidth={1.75} />
|
||||
Re-discover
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
1940
frontend/src/pages/MarketView.tsx
Normal file
1940
frontend/src/pages/MarketView.tsx
Normal file
File diff suppressed because it is too large
Load diff
362
frontend/src/pages/RateAnalysis.tsx
Normal file
362
frontend/src/pages/RateAnalysis.tsx
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import Plot from 'react-plotly.js'
|
||||
import { TrendingUp, TrendingDown, Minus, AlertTriangle, ChevronDown } from 'lucide-react'
|
||||
import api from '../api'
|
||||
|
||||
const fmtDate = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
|
||||
interface AnalysisHotel {
|
||||
hotel_id: number
|
||||
hotel_name: string
|
||||
last_scraped: string | null
|
||||
date_count: number
|
||||
}
|
||||
|
||||
interface StrategyLabel {
|
||||
label: string
|
||||
advance_discount_pct: number
|
||||
weekend_premium_pct: number
|
||||
avg_sold_out_rate_pct: number
|
||||
peak_months: string[]
|
||||
}
|
||||
|
||||
interface HotelAnalysis {
|
||||
strategy: StrategyLabel
|
||||
advance_curve: { days_ahead: number; avg_price: number; sample_count: number }[]
|
||||
dow_breakdown: { dow: number; dow_name: string; avg_price: number; count: number }[]
|
||||
sold_out_pattern: { stay_date: string; sold_out_pct: number }[]
|
||||
}
|
||||
|
||||
interface TimelineEntry {
|
||||
scraped_at: string
|
||||
room_id: string
|
||||
rate_id: string
|
||||
room_label: string
|
||||
rate_label: string
|
||||
price_incl: number | null
|
||||
availability: number
|
||||
}
|
||||
|
||||
interface ComparisonRow {
|
||||
hotel_id: number
|
||||
hotel_name: string
|
||||
our_rate: number | null
|
||||
their_rate: number | null
|
||||
price_index: number | null
|
||||
days_checked: number
|
||||
}
|
||||
|
||||
const DOW = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
const PLOT_LAYOUT_BASE = {
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { family: 'Inter, system-ui, sans-serif', size: 12, color: '#60748b' },
|
||||
margin: { t: 20, r: 16, b: 48, l: 48 },
|
||||
showlegend: false,
|
||||
xaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||
yaxis: { gridcolor: '#e5e9f0', zeroline: false },
|
||||
}
|
||||
|
||||
function strategyIcon(label: string) {
|
||||
if (label.includes('Discount')) return <TrendingDown size={16} strokeWidth={1.75} color="var(--warning)" />
|
||||
if (label.includes('Premium')) return <TrendingUp size={16} strokeWidth={1.75} color="var(--success)" />
|
||||
if (label.includes('Yield')) return <TrendingUp size={16} strokeWidth={1.75} color="var(--gold)" />
|
||||
return <Minus size={16} strokeWidth={1.75} color="var(--text-mid)" />
|
||||
}
|
||||
|
||||
function priceIndexClass(idx: number | null) {
|
||||
if (idx == null) return 'badge badge-neutral'
|
||||
if (idx > 105) return 'badge badge-success'
|
||||
if (idx < 85) return 'badge badge-danger'
|
||||
if (idx < 95) return 'badge badge-warning'
|
||||
return 'badge badge-neutral'
|
||||
}
|
||||
|
||||
export default function RateAnalysis() {
|
||||
const [selectedHotel, setSelectedHotel] = useState<number | null>(null)
|
||||
const [timelineDate, setTimelineDate] = useState(fmtDate(new Date(Date.now() + 30 * 86400000)))
|
||||
const [compFrom, setCompFrom] = useState(fmtDate(new Date()))
|
||||
const [compTo, setCompTo] = useState(fmtDate(new Date(Date.now() + 29 * 86400000)))
|
||||
|
||||
const { data: hotels } = useQuery<AnalysisHotel[]>({
|
||||
queryKey: ['analysis-hotels'],
|
||||
queryFn: () => api.get('/analysis/hotels').then(r => r.data),
|
||||
})
|
||||
|
||||
const { data: analysis, isLoading: analysisLoading } = useQuery<HotelAnalysis>({
|
||||
queryKey: ['analysis-hotel', selectedHotel],
|
||||
queryFn: () => api.get(`/analysis/hotel/${selectedHotel}`).then(r => r.data),
|
||||
enabled: !!selectedHotel,
|
||||
})
|
||||
|
||||
const { data: timeline } = useQuery<TimelineEntry[]>({
|
||||
queryKey: ['analysis-timeline', selectedHotel, timelineDate],
|
||||
queryFn: () => api.get(`/analysis/hotel/${selectedHotel}/timeline`, {
|
||||
params: { date: timelineDate }
|
||||
}).then(r => r.data),
|
||||
enabled: !!selectedHotel,
|
||||
})
|
||||
|
||||
const { data: comparison, isLoading: compLoading } = useQuery<ComparisonRow[]>({
|
||||
queryKey: ['analysis-comparison', compFrom, compTo],
|
||||
queryFn: () => api.get('/analysis/comparison', {
|
||||
params: { from_date: compFrom, to_date: compTo }
|
||||
}).then(r => r.data),
|
||||
enabled: !!(compFrom && compTo),
|
||||
})
|
||||
|
||||
const presets = [
|
||||
{ label: '7d', days: 7 }, { label: '14d', days: 14 },
|
||||
{ label: '30d', days: 30 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Rate Analysis</div>
|
||||
<div className="page-subtitle">Competitor pricing structure and advance purchase behaviour</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparison table — full width, no hotel needed */}
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Market Comparison</span>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
{presets.map(p => (
|
||||
<button key={p.label} className="btn btn-outline btn-sm"
|
||||
onClick={() => { setCompFrom(fmtDate(new Date())); setCompTo(fmtDate(new Date(Date.now() + p.days * 86400000))) }}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<input type="date" style={{ width: 130 }} value={compFrom} onChange={e => setCompFrom(e.target.value)} />
|
||||
<span style={{ color: 'var(--text-mid)', fontSize: 12 }}>to</span>
|
||||
<input type="date" style={{ width: 130 }} value={compTo} onChange={e => setCompTo(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
{compLoading ? (
|
||||
<div className="loading-state"><div className="spinner" />Loading…</div>
|
||||
) : (
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Competitor</th>
|
||||
<th>Our Avg Rate</th>
|
||||
<th>Their Avg Rate</th>
|
||||
<th>Price Index</th>
|
||||
<th>Dates Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(comparison || []).length === 0 && (
|
||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: 'var(--text-mid)', padding: 24 }}>No comparison data available.</td></tr>
|
||||
)}
|
||||
{(comparison || []).map(row => (
|
||||
<tr key={row.hotel_id}>
|
||||
<td>
|
||||
<button onClick={() => setSelectedHotel(row.hotel_id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--gold)', fontWeight: 600, padding: 0, fontSize: 13 }}>
|
||||
{row.hotel_name}
|
||||
</button>
|
||||
</td>
|
||||
<td>{row.our_rate ? `£${Number(row.our_rate).toFixed(2)}` : '—'}</td>
|
||||
<td style={{ fontWeight: 600 }}>{row.their_rate ? `£${Number(row.their_rate).toFixed(2)}` : '—'}</td>
|
||||
<td>
|
||||
{row.price_index != null ? (
|
||||
<span className={priceIndexClass(row.price_index)}>
|
||||
{row.price_index.toFixed(0)}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-mid)' }}>{row.days_checked}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Hotel selector for deep analysis */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||||
Competitor — deep analysis
|
||||
</label>
|
||||
<select style={{ width: 260 }} value={selectedHotel || ''}
|
||||
onChange={e => setSelectedHotel(e.target.value ? parseInt(e.target.value) : null)}>
|
||||
<option value="">Select a competitor…</option>
|
||||
{(hotels || []).map(h => (
|
||||
<option key={h.hotel_id} value={h.hotel_id}>{h.hotel_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedHotel && analysisLoading && (
|
||||
<div className="loading-state"><div className="spinner" />Loading analysis…</div>
|
||||
)}
|
||||
|
||||
{selectedHotel && !analysisLoading && analysis && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||
{/* Strategy card */}
|
||||
<div className="card">
|
||||
<div className="card-header">Pricing Strategy</div>
|
||||
<div className="card-body" style={{ display: 'flex', gap: 32, flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
{strategyIcon(analysis.strategy.label)}
|
||||
<span style={{ fontWeight: 700, fontSize: 15 }}>{analysis.strategy.label}</span>
|
||||
</div>
|
||||
<StatChip label="Advance Discount" value={`${analysis.strategy.advance_discount_pct.toFixed(1)}%`}
|
||||
hint="price delta from 90→7 days ahead" />
|
||||
<StatChip label="Weekend Premium" value={`${analysis.strategy.weekend_premium_pct.toFixed(1)}%`}
|
||||
hint="Fri-Sun vs Mon-Thu" />
|
||||
<StatChip label="Sold-Out Rate" value={`${analysis.strategy.avg_sold_out_rate_pct.toFixed(1)}%`}
|
||||
hint="% of scraped dates with no availability" />
|
||||
{analysis.strategy.peak_months.length > 0 && (
|
||||
<StatChip label="Peak Months" value={analysis.strategy.peak_months.join(', ')} hint="" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
||||
{/* Advance purchase curve */}
|
||||
<div className="card">
|
||||
<div className="card-header">Advance Purchase Curve</div>
|
||||
<div className="card-body" style={{ height: 260 }}>
|
||||
{analysis.advance_curve.length > 0 ? (
|
||||
<Plot
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
data={[{
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
x: analysis.advance_curve.map(p => p.days_ahead),
|
||||
y: analysis.advance_curve.map(p => p.avg_price),
|
||||
line: { color: '#c9a84c', width: 2 },
|
||||
marker: { size: 4, color: '#c9a84c' },
|
||||
hovertemplate: '%{x} days ahead: £%{y:.2f}<extra></extra>',
|
||||
}]}
|
||||
layout={{
|
||||
...PLOT_LAYOUT_BASE,
|
||||
xaxis: { ...PLOT_LAYOUT_BASE.xaxis, title: { text: 'Days ahead', font: { size: 11 } }, autorange: 'reversed' },
|
||||
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, title: { text: 'Avg price (£)', font: { size: 11 } }, tickprefix: '£' },
|
||||
}}
|
||||
config={{ displayModeBar: false, responsive: true }}
|
||||
useResizeHandler
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">Not enough data yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DOW breakdown */}
|
||||
<div className="card">
|
||||
<div className="card-header">Day-of-Week Breakdown</div>
|
||||
<div className="card-body" style={{ height: 260 }}>
|
||||
{analysis.dow_breakdown.length > 0 ? (
|
||||
<Plot
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
data={[{
|
||||
type: 'bar',
|
||||
x: analysis.dow_breakdown.map(d => d.dow_name),
|
||||
y: analysis.dow_breakdown.map(d => d.avg_price),
|
||||
marker: {
|
||||
color: analysis.dow_breakdown.map(d =>
|
||||
d.dow >= 5 ? '#c9a84c' : '#3b82f6'
|
||||
),
|
||||
},
|
||||
hovertemplate: '%{x}: £%{y:.2f}<extra></extra>',
|
||||
}]}
|
||||
layout={{
|
||||
...PLOT_LAYOUT_BASE,
|
||||
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, tickprefix: '£' },
|
||||
}}
|
||||
config={{ displayModeBar: false, responsive: true }}
|
||||
useResizeHandler
|
||||
/>
|
||||
) : (
|
||||
<div className="empty-state">Not enough data yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate timeline */}
|
||||
<div className="card">
|
||||
<div className="card-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>Rate Timeline — How Rates Changed for One Date</span>
|
||||
<input type="date" style={{ width: 140 }} value={timelineDate}
|
||||
onChange={e => setTimelineDate(e.target.value)} />
|
||||
</div>
|
||||
<div className="card-body" style={{ height: 280 }}>
|
||||
{(timeline || []).length === 0 ? (
|
||||
<div className="empty-state">No timeline data for this date.</div>
|
||||
) : (
|
||||
<Plot
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
data={buildTimelineTraces(timeline || [])}
|
||||
layout={{
|
||||
...PLOT_LAYOUT_BASE,
|
||||
showlegend: true,
|
||||
legend: { font: { size: 11 }, bgcolor: 'transparent' },
|
||||
margin: { t: 20, r: 120, b: 48, l: 56 },
|
||||
xaxis: { ...PLOT_LAYOUT_BASE.xaxis },
|
||||
yaxis: { ...PLOT_LAYOUT_BASE.yaxis, tickprefix: '£' },
|
||||
}}
|
||||
config={{ displayModeBar: false, responsive: true }}
|
||||
useResizeHandler
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!selectedHotel && (
|
||||
<div className="empty-state" style={{ marginTop: 0 }}>
|
||||
Select a competitor above to view their pricing strategy and advance purchase curve.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatChip({ label, value, hint }: { label: string; value: string; hint: string }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-mid)', fontWeight: 500, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{label}</span>
|
||||
<span style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-dark)', lineHeight: 1 }}>{value}</span>
|
||||
{hint && <span style={{ fontSize: 11, color: 'var(--text-mid)' }}>{hint}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildTimelineTraces(entries: TimelineEntry[]) {
|
||||
const byRoom: Record<string, TimelineEntry[]> = {}
|
||||
for (const e of entries) {
|
||||
const key = e.room_label || e.room_id
|
||||
if (!byRoom[key]) byRoom[key] = []
|
||||
byRoom[key].push(e)
|
||||
}
|
||||
|
||||
const colors = ['#c9a84c', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b']
|
||||
return Object.entries(byRoom).map(([room, pts], i) => ({
|
||||
type: 'scatter' as const,
|
||||
mode: 'lines+markers' as const,
|
||||
name: room,
|
||||
x: pts.map(p => p.scraped_at),
|
||||
y: pts.map(p => p.price_incl),
|
||||
line: { color: colors[i % colors.length], width: 2 },
|
||||
marker: { size: 5, color: colors[i % colors.length] },
|
||||
hovertemplate: `${room}: £%{y:.2f}<extra></extra>`,
|
||||
}))
|
||||
}
|
||||
224
frontend/src/pages/Settings.tsx
Normal file
224
frontend/src/pages/Settings.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
import { useState } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Save, RefreshCw, Database, Clock } from 'lucide-react'
|
||||
import api from '../api'
|
||||
|
||||
const TABS = [
|
||||
{ id: 'newbook', label: 'Newbook Sync' },
|
||||
{ id: 'system', label: 'System' },
|
||||
]
|
||||
|
||||
interface SystemConfig {
|
||||
[key: string]: string | null
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const { tab: tabParam } = useParams<{ tab?: string }>()
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
const activeTab = tabParam || 'newbook'
|
||||
|
||||
const { data: config, isLoading } = useQuery<SystemConfig>({
|
||||
queryKey: ['system-config'],
|
||||
queryFn: () => api.get('/competitors/config/system').then(r => r.data),
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload: { key: string; value: string }) =>
|
||||
api.post('/competitors/config/system', payload),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
|
||||
})
|
||||
|
||||
const syncNow = useMutation({
|
||||
mutationFn: () => api.post('/bookability/refresh-all'),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<div className="page-title">Settings</div>
|
||||
<div className="page-subtitle">Newbook sync and system configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sub-nav">
|
||||
{TABS.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
className={`sub-nav-item${activeTab === t.id ? ' active' : ''}`}
|
||||
onClick={() => navigate(`/settings/${t.id}`)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeTab === 'newbook' && (
|
||||
<NewbookTab
|
||||
config={config}
|
||||
isLoading={isLoading}
|
||||
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
|
||||
onSyncNow={() => syncNow.mutate()}
|
||||
saving={saveMutation.isPending}
|
||||
syncing={syncNow.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'system' && (
|
||||
<SystemTab config={config} isLoading={isLoading} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Newbook Sync Tab ─────────────────────────────────────────────────────────
|
||||
|
||||
interface NewbookTabProps {
|
||||
config: SystemConfig | undefined
|
||||
isLoading: boolean
|
||||
onSave: (key: string, val: string) => void
|
||||
onSyncNow: () => void
|
||||
saving: boolean
|
||||
syncing: boolean
|
||||
}
|
||||
|
||||
function NewbookTab({ config, isLoading, onSave, onSyncNow, saving, syncing }: NewbookTabProps) {
|
||||
const [syncTime, setSyncTime] = useState('')
|
||||
|
||||
const syncEnabled = config?.sync_newbook_current_rates_enabled === 'true'
|
||||
const currentTime = config?.sync_newbook_current_rates_time || '05:20'
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="loading-state"><div className="spinner" /> Loading…</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 600 }}>
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Database size={16} strokeWidth={1.75} />
|
||||
Newbook Rates Sync
|
||||
</span>
|
||||
<span className={`badge ${syncEnabled ? 'badge-success' : 'badge-neutral'}`}>
|
||||
{syncEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||||
When enabled, the app fetches current tariff rates from the Newbook API daily and
|
||||
stores them for the Bookability view and rate parity calculations.
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<button
|
||||
className={`btn ${syncEnabled ? 'btn-outline' : 'btn-primary'}`}
|
||||
onClick={() => onSave('sync_newbook_current_rates_enabled', syncEnabled ? 'false' : 'true')}
|
||||
disabled={saving}
|
||||
>
|
||||
{syncEnabled ? 'Disable Sync' : 'Enable Sync'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
onClick={onSyncNow}
|
||||
disabled={syncing}
|
||||
>
|
||||
<RefreshCw size={14} strokeWidth={1.75} />
|
||||
{syncing ? 'Refreshing…' : 'Sync Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Clock size={16} strokeWidth={1.75} />
|
||||
Sync Schedule
|
||||
</span>
|
||||
</div>
|
||||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6 }}>
|
||||
Daily sync time (HH:MM)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<input
|
||||
type="time"
|
||||
style={{ width: 130 }}
|
||||
defaultValue={currentTime}
|
||||
onChange={e => setSyncTime(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => syncTime && onSave('sync_newbook_current_rates_time', syncTime)}
|
||||
disabled={saving || !syncTime}
|
||||
>
|
||||
<Save size={13} strokeWidth={1.75} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-mid)', marginTop: 6 }}>
|
||||
Current: {currentTime} — Booking.com scraper runs at {config?.booking_scraper_daily_time || '05:30'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── System Tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function SystemTab({ config, isLoading }: { config: SystemConfig | undefined; isLoading: boolean }) {
|
||||
if (isLoading) {
|
||||
return <div className="loading-state"><div className="spinner" /> Loading…</div>
|
||||
}
|
||||
|
||||
const displayKeys = [
|
||||
'booking_scraper_enabled',
|
||||
'booking_scraper_paused',
|
||||
'booking_scraper_backend',
|
||||
'booking_scraper_daily_time',
|
||||
'sync_newbook_current_rates_enabled',
|
||||
'sync_newbook_current_rates_time',
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 700 }}>
|
||||
<div className="card">
|
||||
<div className="card-header">System Configuration</div>
|
||||
<div className="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{displayKeys.map(k => (
|
||||
<tr key={k}>
|
||||
<td><code style={{ fontSize: 12, color: 'var(--text-mid)' }}>{k}</code></td>
|
||||
<td>
|
||||
<span style={{ fontSize: 13 }}>
|
||||
{config?.[k] ?? <em style={{ color: 'var(--text-mid)' }}>not set</em>}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-mid)' }}>
|
||||
To configure the Booking.com scraper location and hotel tiers, use the Settings tab inside{' '}
|
||||
<a href="/rates/market" style={{ color: 'var(--gold)' }}>Market View</a>.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/types.ts
Normal file
114
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
export interface User {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
caps: string[]
|
||||
}
|
||||
|
||||
export function can(user: User | null, cap: string): boolean {
|
||||
if (!user) return false
|
||||
return user.is_admin || user.caps.includes(cap)
|
||||
}
|
||||
|
||||
export interface Hotel {
|
||||
id: number
|
||||
name: string
|
||||
tier: 'own' | 'competitor' | 'market'
|
||||
star_rating: number | null
|
||||
review_score: number | null
|
||||
booking_com_url: string | null
|
||||
booking_com_id: string | null
|
||||
display_order: number
|
||||
notes: string | null
|
||||
is_active: boolean
|
||||
scraped_dates?: number
|
||||
last_scraped?: string | null
|
||||
}
|
||||
|
||||
export interface RateMatrixEntry {
|
||||
hotel_id: number
|
||||
hotel_name: string
|
||||
tier: string
|
||||
rate_date: string
|
||||
rate_gross: number | null
|
||||
availability_status: string
|
||||
rooms_left: number | null
|
||||
scraped_at: string | null
|
||||
}
|
||||
|
||||
export interface ScraperStatus {
|
||||
enabled: boolean
|
||||
paused: boolean
|
||||
pause_until: string | null
|
||||
backend: string
|
||||
daily_time: string
|
||||
last_batch: {
|
||||
batch_id: string
|
||||
started_at: string
|
||||
status: string
|
||||
rates_scraped: number
|
||||
dates_completed: number
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface BookabilityCategory {
|
||||
category_id: string
|
||||
category_name: string
|
||||
room_count: number
|
||||
display_order: number
|
||||
dates: BookabilityDate[]
|
||||
}
|
||||
|
||||
export interface BookabilityDate {
|
||||
date: string
|
||||
gross_rate: number | null
|
||||
tariffs: TariffSummary[]
|
||||
occupancy_pct: number | null
|
||||
available_rooms: number | null
|
||||
is_bookable: boolean
|
||||
}
|
||||
|
||||
export interface TariffSummary {
|
||||
tariff_name: string
|
||||
rate: number | null
|
||||
min_stay: number | null
|
||||
advance_max: number | null
|
||||
is_available: boolean
|
||||
}
|
||||
|
||||
export interface AdvancePurchaseCurvePoint {
|
||||
lead_bucket: string
|
||||
avg_rate: number
|
||||
sample_count: number
|
||||
}
|
||||
|
||||
export interface DowAnalysisPoint {
|
||||
dow: number
|
||||
dow_label: string
|
||||
avg_rate: number
|
||||
date_count: number
|
||||
}
|
||||
|
||||
export interface StrategySummary {
|
||||
advance_discount_pct: number | null
|
||||
weekend_premium_pct: number | null
|
||||
avg_sold_out_rate_pct: number | null
|
||||
strategy_label: string
|
||||
}
|
||||
|
||||
export interface HotelAnalysis {
|
||||
hotel: Hotel
|
||||
date_range: { from: string; to: string }
|
||||
advance_purchase_curve: AdvancePurchaseCurvePoint[]
|
||||
dow_analysis: DowAnalysisPoint[]
|
||||
sold_out_pattern: Array<{ dow: number; dow_label: string; total_dates: number; sold_out_dates: number }>
|
||||
strategy_summary: StrategySummary
|
||||
}
|
||||
|
||||
export interface RateTimelinePoint {
|
||||
scraped_at: string
|
||||
rate_gross: number | null
|
||||
availability_status: string
|
||||
rooms_left: number | null
|
||||
days_out: number
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue