import { useState, useEffect } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Save, RefreshCw, Database, Clock, BedDouble, ChevronUp, ChevronDown, Network, ShieldCheck } from 'lucide-react' import api from '../api' const TABS = [ { id: 'newbook', label: 'Newbook Sync' }, { id: 'proxy', label: 'Scraper Proxy' }, { id: 'parity', label: 'Rate Parity' }, { id: 'system', label: 'System' }, ] interface SystemConfig { [key: string]: string | null } interface RoomCategory { id: number site_id: string site_name: string room_count: number is_included: boolean display_order: number } 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({ 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-rates'), onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }), }) return (
Settings
Newbook sync and system configuration
{TABS.map(t => ( ))}
{activeTab === 'newbook' && ( saveMutation.mutate({ key, value: val })} onSyncNow={() => syncNow.mutate()} saving={saveMutation.isPending} syncing={syncNow.isPending} /> )} {activeTab === 'proxy' && ( )} {activeTab === 'parity' && ( saveMutation.mutate({ key, value: val })} saving={saveMutation.isPending} /> )} {activeTab === 'system' && ( )}
) } // ─── Rate Parity Tab ────────────────────────────────────────────────────────── interface ParityCheckResult { status: string dates_compared?: number created?: number updated?: number resolved?: number } function ParityTab({ config, isLoading, onSave, saving }: { config: SystemConfig | undefined isLoading: boolean onSave: (key: string, value: string) => void saving: boolean }) { const [markup, setMarkup] = useState('') const [markupUnit, setMarkupUnit] = useState('pct') const [tolerance, setTolerance] = useState('') const [toleranceUnit, setToleranceUnit] = useState('pct') const [loaded, setLoaded] = useState(false) const [checkResult, setCheckResult] = useState(null) useEffect(() => { if (config && !loaded) { setMarkup(config['parity_markup_value'] ?? config['parity_expected_markup_pct'] ?? '0') setMarkupUnit(config['parity_markup_unit'] ?? 'pct') setTolerance(config['parity_tolerance_value'] ?? config['parity_tolerance_pct'] ?? '2') setToleranceUnit(config['parity_tolerance_unit'] ?? 'pct') setLoaded(true) } }, [config, loaded]) const enabled = (config?.['parity_check_enabled'] ?? 'true').toLowerCase() !== 'false' const runCheck = useMutation({ mutationFn: () => api.post('/competitors/parity/check').then(r => r.data), onSuccess: (data) => setCheckResult(data), }) if (isLoading) return
Loading…
return (
Rate Parity Check
Compares our own hotel's Booking.com rate against the Newbook rate each morning (06:45). We deliberately price Booking.com higher to cover commission, so the check measures against an expected markup rather than raw equality: alert when Booking.com deviates from Newbook × (1 + markup) by more than the tolerance. Alerts appear on the Market View badge; acknowledging a date suppresses re-alerts for it, and dates that come back in line auto-resolve.
Best available compares our cheapest bookable direct tariff against the Booking.com lead-in rate (Booking.com's best available). Match terms instead picks the direct tariff with the same conditions as the scraped Booking.com rate — only differs on days where Booking.com's cheapest is flexible while a cheaper prepaid exists direct.
setMarkup(e.target.value)} placeholder={markupUnit === 'gbp' ? 'e.g. 20' : 'e.g. 15'} />
How much higher Booking.com should be than Newbook {markupUnit === 'gbp' ? ' (flat £ per night)' : ' (percentage)'}.
setTolerance(e.target.value)} placeholder={toleranceUnit === 'gbp' ? 'e.g. 5' : 'e.g. 2'} />
Allowed deviation from expected before alerting.
Rule: alert when Booking.com ≠ Newbook {markupUnit === 'gbp' ? `+ £${markup || '0'}` : `+ ${markup || '0'}%`} {' '}beyond ±{toleranceUnit === 'gbp' ? `£${tolerance || '0'}` : `${tolerance || '0'}%`}. {' '}Example: Newbook £100 → expect £{(markupUnit === 'gbp' ? 100 + (parseFloat(markup) || 0) : 100 * (1 + (parseFloat(markup) || 0) / 100)).toFixed(0)}, {' '}alert outside £{(() => { const exp = markupUnit === 'gbp' ? 100 + (parseFloat(markup) || 0) : 100 * (1 + (parseFloat(markup) || 0) / 100) const tol = toleranceUnit === 'gbp' ? (parseFloat(tolerance) || 0) : exp * (parseFloat(tolerance) || 0) / 100 return `${(exp - tol).toFixed(0)}–£${(exp + tol).toFixed(0)}` })()}.
{checkResult && (
{checkResult.status === 'disabled' ? 'Check is disabled — enable it above first.' : `Compared ${checkResult.dates_compared} dates — ${checkResult.created} new alerts, ${checkResult.updated} updated, ${checkResult.resolved} resolved.`}
)}
) } // ─── Scraper Proxy Tab ──────────────────────────────────────────────────────── interface ProxyConfigData { enabled: boolean host: string port: string username: string country: string password_set: boolean } interface ProxyTestResult { ok: boolean ip: string country: string city: string org: string } function ProxyTab() { const qc = useQueryClient() const { data: cfg, isLoading } = useQuery({ queryKey: ['proxy-config'], queryFn: () => api.get('/competitors/config/proxy').then(r => r.data), }) const [enabled, setEnabled] = useState(false) const [host, setHost] = useState('') const [port, setPort] = useState('823') const [username, setUsername] = useState('') const [country, setCountry] = useState('gb') const [password, setPassword] = useState('') const [testResult, setTestResult] = useState(null) const [testError, setTestError] = useState(null) const [workers, setWorkers] = useState('3') const { data: sysCfg } = useQuery({ queryKey: ['system-config'], queryFn: () => api.get('/competitors/config/system').then(r => r.data), }) // Seed form once the stored config arrives (password stays blank by design) useEffect(() => { if (cfg) { setEnabled(cfg.enabled) setHost(cfg.host) setPort(cfg.port) setUsername(cfg.username) setCountry(cfg.country) } }, [cfg]) useEffect(() => { if (sysCfg?.booking_scraper_concurrency) setWorkers(String(sysCfg.booking_scraper_concurrency)) }, [sysCfg]) const saveWorkers = useMutation({ mutationFn: () => api.post('/competitors/config/system', { key: 'booking_scraper_concurrency', value: String(Math.max(1, parseInt(workers) || 1)), }), onSuccess: () => qc.invalidateQueries({ queryKey: ['system-config'] }), }) const save = useMutation({ mutationFn: () => api.post('/competitors/config/proxy', { enabled, host, port, username, country, // Only send a password when the user typed a new one password: password || undefined, }), onSuccess: () => { setPassword('') qc.invalidateQueries({ queryKey: ['proxy-config'] }) qc.invalidateQueries({ queryKey: ['system-config'] }) }, }) const runTest = useMutation({ mutationFn: () => api.post('/competitors/config/proxy/test').then(r => r.data as ProxyTestResult), onMutate: () => { setTestResult(null); setTestError(null) }, onSuccess: (data) => setTestResult(data), onError: (err: any) => setTestError(err?.response?.data?.detail || 'Test failed'), }) if (isLoading) { return
Loading…
} const labelStyle: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6, } return (
Residential Proxy {enabled ? 'Enabled' : 'Disabled'}

Routes the Booking.com scraper through a residential proxy (e.g. DataImpulse). One sticky IP is held per scrape session and rotated automatically if a page is blocked. Leave disabled to scrape from this server's own IP.

setHost(e.target.value)} style={{ width: '100%' }} />
setPort(e.target.value)} style={{ width: '100%' }} />
setUsername(e.target.value)} style={{ width: '100%' }} />
setPassword(e.target.value)} style={{ width: '100%' }} />
setCountry(e.target.value.toLowerCase())} style={{ width: '100%' }} />
{save.isError && (

{(save.error as any)?.response?.data?.detail || 'Save failed'}

)} {testResult && (
✓ Connected — exit IP {testResult.ip} ({testResult.country} {testResult.city ? `, ${testResult.city}` : ''}){testResult.org ? ` · ${testResult.org}` : ''}
)} {testError && (

{testError}

)}
Parallel Workers

How many dates to scrape at once. Each worker runs its own browser on its own residential IP, so a run finishes roughly this many times faster. Only takes effect when the proxy is enabled (without it, all workers would share one IP). Budget about 0.4 GB of RAM per worker — 3 is safe on the current 4 GB; raise it after adding RAM.

setWorkers(e.target.value)} style={{ width: 90 }} /> {!enabled && ( Enable the proxy above for this to apply )}
) } // ─── 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
Loading…
} return (
Newbook Rates Sync {syncEnabled ? 'Enabled' : 'Disabled'}

When enabled, the app fetches current tariff rates from the Newbook API daily and stores them for the Bookability view and rate parity calculations.

Sync Schedule
setSyncTime(e.target.value)} />

Current: {currentTime} — Booking.com scraper runs at {config?.booking_scraper_daily_time || '05:30'}

) } // ─── Room Categories ────────────────────────────────────────────────────────── function RoomCategoriesCard() { const qc = useQueryClient() const { data: categories, isLoading } = useQuery({ queryKey: ['room-categories'], queryFn: () => api.get('/bookability/categories').then(r => r.data), }) const syncCategories = useMutation({ mutationFn: () => api.post('/bookability/categories/sync'), onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }), }) const toggleCategory = useMutation({ mutationFn: ({ id, included }: { id: number; included: boolean }) => api.patch(`/bookability/categories/${id}`, { is_included: included }), onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }), }) const reorderCategories = useMutation({ mutationFn: (ordered: RoomCategory[]) => Promise.all(ordered.map((cat, idx) => api.patch(`/bookability/categories/${cat.id}`, { display_order: (idx + 1) * 10 }) )), onSuccess: () => qc.invalidateQueries({ queryKey: ['room-categories'] }), }) const moveCategory = (idx: number, dir: -1 | 1) => { if (!categories) return const target = idx + dir if (target < 0 || target >= categories.length) return const reordered = [...categories] ;[reordered[idx], reordered[target]] = [reordered[target], reordered[idx]] reorderCategories.mutate(reordered) } return (
Room Categories

Rate syncs only fetch tariffs for the included categories below. If the list is empty, fetch categories from Newbook first — otherwise syncs will do nothing.

{syncCategories.isError && (

{(syncCategories.error as any)?.response?.data?.detail || 'Category fetch failed'}

)} {isLoading ? (
Loading…
) : !categories?.length ? (

No room categories yet — click “Fetch from Newbook” to load them.

) : (
{categories.map((cat, idx) => ( ))}
Included Category Rooms Order
toggleCategory.mutate({ id: cat.id, included: e.target.checked })} disabled={toggleCategory.isPending} /> {cat.site_name} {cat.room_count}
)}
) } // ─── System Tab ─────────────────────────────────────────────────────────────── function SystemTab({ config, isLoading }: { config: SystemConfig | undefined; isLoading: boolean }) { if (isLoading) { return
Loading…
} const displayKeys = [ 'booking_scraper_enabled', 'booking_scraper_backend', 'booking_scraper_daily_time', 'booking_proxy_enabled', 'booking_proxy_host', 'booking_proxy_country', 'booking_scraper_concurrency', 'sync_newbook_current_rates_enabled', 'sync_newbook_current_rates_time', ] return (
System Configuration
{displayKeys.map(k => ( ))}
Key Value
{k} {config?.[k] ?? not set}
To configure the Booking.com scraper location and hotel tiers, use the Settings tab inside{' '} Market View.
) }