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

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