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:
jtricerolph 2026-07-05 12:06:30 +00:00
commit e05054172f
50 changed files with 11860 additions and 0 deletions

File diff suppressed because it is too large Load diff

View 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>
)
}

File diff suppressed because it is too large Load diff

View 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>`,
}))
}

View 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>
)
}