parity_match_mode config ('best_available' default | 'match_terms').
Best available = cheapest bookable non-dinner direct tariff vs the BC
lead-in (already BC's best available) — simplest like-for-like. Term
matching kept as an option for days where BC's cheapest basis differs
from direct's. Settings gains a comparison-basis select.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
774 lines
31 KiB
TypeScript
774 lines
31 KiB
TypeScript
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<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-rates'),
|
||
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 === 'proxy' && (
|
||
<ProxyTab />
|
||
)}
|
||
|
||
{activeTab === 'parity' && (
|
||
<ParityTab
|
||
config={config}
|
||
isLoading={isLoading}
|
||
onSave={(key, val) => saveMutation.mutate({ key, value: val })}
|
||
saving={saveMutation.isPending}
|
||
/>
|
||
)}
|
||
|
||
{activeTab === 'system' && (
|
||
<SystemTab config={config} isLoading={isLoading} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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<ParityCheckResult | null>(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 <div className="loading-state"><div className="spinner" />Loading…</div>
|
||
|
||
return (
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, maxWidth: 720 }}>
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<ShieldCheck size={15} strokeWidth={1.75} style={{ verticalAlign: -2, marginRight: 6 }} />
|
||
Rate Parity Check
|
||
</div>
|
||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||
<div style={{ fontSize: 12, color: 'var(--text-mid)' }}>
|
||
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 <strong>expected markup</strong> 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.
|
||
</div>
|
||
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 10, cursor: 'pointer', fontSize: 13 }}>
|
||
<input
|
||
type="checkbox"
|
||
checked={enabled}
|
||
onChange={e => onSave('parity_check_enabled', e.target.checked ? 'true' : 'false')}
|
||
style={{ width: 15, height: 15, accentColor: 'var(--gold)' }}
|
||
/>
|
||
Run daily parity check
|
||
</label>
|
||
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||
Comparison basis
|
||
</label>
|
||
<select
|
||
style={{ width: 330 }}
|
||
value={(config?.['parity_match_mode'] ?? 'best_available')}
|
||
onChange={e => onSave('parity_match_mode', e.target.value)}
|
||
>
|
||
<option value="best_available">Best available (cheapest bookable tariff on both sides)</option>
|
||
<option value="match_terms">Match terms (flex vs flex, prepaid vs prepaid)</option>
|
||
</select>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4, maxWidth: 480 }}>
|
||
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.
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||
Expected Booking.com markup
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input
|
||
type="number" step="0.5" style={{ width: 110 }}
|
||
value={markup} onChange={e => setMarkup(e.target.value)}
|
||
placeholder={markupUnit === 'gbp' ? 'e.g. 20' : 'e.g. 15'}
|
||
/>
|
||
<select style={{ width: 64 }} value={markupUnit} onChange={e => setMarkupUnit(e.target.value)}>
|
||
<option value="pct">%</option>
|
||
<option value="gbp">£</option>
|
||
</select>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||
How much higher Booking.com should be than Newbook
|
||
{markupUnit === 'gbp' ? ' (flat £ per night)' : ' (percentage)'}.
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 4 }}>
|
||
Tolerance (±)
|
||
</label>
|
||
<div style={{ display: 'flex', gap: 6 }}>
|
||
<input
|
||
type="number" step="0.5" style={{ width: 110 }}
|
||
value={tolerance} onChange={e => setTolerance(e.target.value)}
|
||
placeholder={toleranceUnit === 'gbp' ? 'e.g. 5' : 'e.g. 2'}
|
||
/>
|
||
<select style={{ width: 64 }} value={toleranceUnit} onChange={e => setToleranceUnit(e.target.value)}>
|
||
<option value="pct">%</option>
|
||
<option value="gbp">£</option>
|
||
</select>
|
||
</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-mid)', marginTop: 4 }}>
|
||
Allowed deviation from expected before alerting.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ padding: '8px 12px', background: '#f8fafc', borderRadius: 6, fontSize: 12, color: 'var(--text-mid)' }}>
|
||
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)}`
|
||
})()}.
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 8 }}>
|
||
<button
|
||
className="btn btn-primary"
|
||
disabled={saving}
|
||
onClick={() => {
|
||
onSave('parity_markup_value', markup || '0')
|
||
onSave('parity_markup_unit', markupUnit)
|
||
onSave('parity_tolerance_value', tolerance || '2')
|
||
onSave('parity_tolerance_unit', toleranceUnit)
|
||
}}
|
||
>
|
||
<Save size={13} strokeWidth={1.75} />
|
||
Save
|
||
</button>
|
||
<button
|
||
className="btn btn-outline"
|
||
disabled={runCheck.isPending}
|
||
onClick={() => runCheck.mutate()}
|
||
>
|
||
<RefreshCw size={13} strokeWidth={1.75} />
|
||
{runCheck.isPending ? 'Checking…' : 'Run check now'}
|
||
</button>
|
||
</div>
|
||
|
||
{checkResult && (
|
||
<div style={{ padding: '10px 14px', background: '#f0fdf4', borderRadius: 8, border: '1px solid #bbf7d0', fontSize: 13 }}>
|
||
{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.`}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 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<ProxyConfigData>({
|
||
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<ProxyTestResult | null>(null)
|
||
const [testError, setTestError] = useState<string | null>(null)
|
||
const [workers, setWorkers] = useState('3')
|
||
|
||
const { data: sysCfg } = useQuery<SystemConfig>({
|
||
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 <div className="loading-state"><div className="spinner" /> Loading…</div>
|
||
}
|
||
|
||
const labelStyle: React.CSSProperties = {
|
||
fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', display: 'block', marginBottom: 6,
|
||
}
|
||
|
||
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 }}>
|
||
<Network size={16} strokeWidth={1.75} />
|
||
Residential Proxy
|
||
</span>
|
||
<span className={`badge ${enabled ? 'badge-success' : 'badge-neutral'}`}>
|
||
{enabled ? '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 }}>
|
||
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.
|
||
</p>
|
||
|
||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 }}>
|
||
<input type="checkbox" checked={enabled} onChange={e => setEnabled(e.target.checked)} />
|
||
Enable proxy for scraping
|
||
</label>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||
<div>
|
||
<label style={labelStyle}>Proxy host</label>
|
||
<input type="text" placeholder="gw.dataimpulse.com" value={host}
|
||
onChange={e => setHost(e.target.value)} style={{ width: '100%' }} />
|
||
</div>
|
||
<div>
|
||
<label style={labelStyle}>Port</label>
|
||
<input type="text" placeholder="823" value={port}
|
||
onChange={e => setPort(e.target.value)} style={{ width: '100%' }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<label style={labelStyle}>Username / login</label>
|
||
<input type="text" placeholder="account login" value={username}
|
||
onChange={e => setUsername(e.target.value)} style={{ width: '100%' }} />
|
||
</div>
|
||
|
||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12 }}>
|
||
<div>
|
||
<label style={labelStyle}>Password</label>
|
||
<input type="password" placeholder={cfg?.password_set ? '•••••••• (unchanged)' : 'not set'}
|
||
value={password} onChange={e => setPassword(e.target.value)} style={{ width: '100%' }} />
|
||
</div>
|
||
<div>
|
||
<label style={labelStyle}>Country</label>
|
||
<input type="text" placeholder="gb" value={country}
|
||
onChange={e => setCountry(e.target.value.toLowerCase())} style={{ width: '100%' }} />
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||
<button className="btn btn-primary" onClick={() => save.mutate()} disabled={save.isPending}>
|
||
<Save size={14} strokeWidth={1.75} />
|
||
{save.isPending ? 'Saving…' : 'Save'}
|
||
</button>
|
||
<button className="btn btn-outline" onClick={() => runTest.mutate()} disabled={runTest.isPending}>
|
||
<ShieldCheck size={14} strokeWidth={1.75} />
|
||
{runTest.isPending ? 'Testing…' : 'Test Connection'}
|
||
</button>
|
||
</div>
|
||
|
||
{save.isError && (
|
||
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
|
||
{(save.error as any)?.response?.data?.detail || 'Save failed'}
|
||
</p>
|
||
)}
|
||
{testResult && (
|
||
<div style={{ fontSize: 13, color: 'var(--success)', background: 'var(--bg-subtle, #f0fdf4)',
|
||
padding: '10px 12px', borderRadius: 6, border: '1px solid var(--success)' }}>
|
||
✓ Connected — exit IP <strong>{testResult.ip}</strong> ({testResult.country}
|
||
{testResult.city ? `, ${testResult.city}` : ''}){testResult.org ? ` · ${testResult.org}` : ''}
|
||
</div>
|
||
)}
|
||
{testError && (
|
||
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>{testError}</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<Network size={16} strokeWidth={1.75} />
|
||
Parallel Workers
|
||
</span>
|
||
</div>
|
||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||
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.
|
||
</p>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<input type="number" min={1} max={12} value={workers}
|
||
onChange={e => setWorkers(e.target.value)} style={{ width: 90 }} />
|
||
<button className="btn btn-primary btn-sm" onClick={() => saveWorkers.mutate()}
|
||
disabled={saveWorkers.isPending}>
|
||
<Save size={13} strokeWidth={1.75} />
|
||
{saveWorkers.isPending ? 'Saving…' : 'Save'}
|
||
</button>
|
||
{!enabled && (
|
||
<span style={{ fontSize: 12, color: 'var(--warning)' }}>
|
||
Enable the proxy above for this to apply
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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>
|
||
|
||
<RoomCategoriesCard />
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── Room Categories ──────────────────────────────────────────────────────────
|
||
|
||
function RoomCategoriesCard() {
|
||
const qc = useQueryClient()
|
||
|
||
const { data: categories, isLoading } = useQuery<RoomCategory[]>({
|
||
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 (
|
||
<div className="card">
|
||
<div className="card-header">
|
||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||
<BedDouble size={16} strokeWidth={1.75} />
|
||
Room Categories
|
||
</span>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
onClick={() => syncCategories.mutate()}
|
||
disabled={syncCategories.isPending}
|
||
>
|
||
<RefreshCw size={13} strokeWidth={1.75} />
|
||
{syncCategories.isPending ? 'Fetching…' : 'Fetch from Newbook'}
|
||
</button>
|
||
</div>
|
||
<div className="card-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||
<p style={{ fontSize: 13, color: 'var(--text-mid)', margin: 0 }}>
|
||
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.
|
||
</p>
|
||
{syncCategories.isError && (
|
||
<p style={{ fontSize: 13, color: 'var(--danger)', margin: 0 }}>
|
||
{(syncCategories.error as any)?.response?.data?.detail || 'Category fetch failed'}
|
||
</p>
|
||
)}
|
||
{isLoading ? (
|
||
<div className="loading-state"><div className="spinner" /> Loading…</div>
|
||
) : !categories?.length ? (
|
||
<p style={{ fontSize: 13, color: 'var(--warning)', margin: 0 }}>
|
||
No room categories yet — click “Fetch from Newbook” to load them.
|
||
</p>
|
||
) : (
|
||
<div className="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Included</th>
|
||
<th>Category</th>
|
||
<th>Rooms</th>
|
||
<th>Order</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{categories.map((cat, idx) => (
|
||
<tr key={cat.id}>
|
||
<td>
|
||
<input
|
||
type="checkbox"
|
||
checked={cat.is_included}
|
||
onChange={e => toggleCategory.mutate({ id: cat.id, included: e.target.checked })}
|
||
disabled={toggleCategory.isPending}
|
||
/>
|
||
</td>
|
||
<td>{cat.site_name}</td>
|
||
<td>{cat.room_count}</td>
|
||
<td>
|
||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ padding: '2px 6px' }}
|
||
onClick={() => moveCategory(idx, -1)}
|
||
disabled={idx === 0 || reorderCategories.isPending}
|
||
title="Move up"
|
||
>
|
||
<ChevronUp size={13} strokeWidth={1.75} />
|
||
</button>
|
||
<button
|
||
className="btn btn-outline btn-sm"
|
||
style={{ padding: '2px 6px' }}
|
||
onClick={() => moveCategory(idx, 1)}
|
||
disabled={idx === categories.length - 1 || reorderCategories.isPending}
|
||
title="Move down"
|
||
>
|
||
<ChevronDown size={13} strokeWidth={1.75} />
|
||
</button>
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</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_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 (
|
||
<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>
|
||
)
|
||
}
|