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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue