rates/frontend/src/pages/MarketView.tsx
jtricerolph 121976b3db Fix Plotly £ format warning and add scraper backend selector to Settings
- MarketView: split tickformat '£,.0f' into tickprefix+'£' + tickformat ',.0f' (Plotly d3 format doesn't accept £ prefix inline)
- Settings System tab: add editable dropdown for booking_scraper_backend (hotel page vs search results) with description of the tradeoff; backend was previously read-only in the table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-09 23:45:33 +00:00

2493 lines
89 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useMemo, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Eye, RefreshCw, LineChart, X } from 'lucide-react'
import Plot from 'react-plotly.js'
import api from '../api'
// Format Date as YYYY-MM-DD using local time (avoids UTC/DST shift from toISOString)
const fmtDate = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
// Booking.com search results for the scrape location on a given night
const buildSearchUrl = (location: string, checkin: string) => {
const co = new Date(checkin + 'T00:00:00')
co.setDate(co.getDate() + 1)
const params = new URLSearchParams({
ss: location,
checkin,
checkout: fmtDate(co),
group_adults: '2',
no_rooms: '1',
group_children: '0',
})
return `https://www.booking.com/searchresults.html?${params}`
}
// ============================================
// TYPES
// ============================================
interface ScrapeJob {
from: string
to: string
}
interface ScraperStatus {
enabled: boolean
backend: string
location_configured: boolean
location_name: string | null
last_scrape: {
batch_id: string
scrape_type: string
started_at: string | null
completed_at: string | null
status: string
hotels_found: number | null
rates_scraped: number | null
error_message: string | null
} | null
}
interface Hotel {
id: number
booking_com_id: string
name: string
booking_com_url: string | null
star_rating: number | null
review_score: number | null
review_count: number | null
tier: 'own' | 'competitor' | 'market'
display_order: number
notes: string | null
first_seen_at: string | null
last_seen_at: string | null
direct_hotel_id?: number | null
}
interface RateMatrixResponse {
from_date: string
to_date: string
dates: string[]
last_scraped?: Record<string, string | null>
hotels: {
id: number
name: string
tier: string
display_order: number
star_rating: number | null
review_score: number | null
booking_com_url: string | null
direct_hotel_id?: number | null
}[]
rates: Record<number, Record<string, {
availability_status: string
rate_gross: number | null
last_available_rate: number | null
room_type: string | null
breakfast_included: boolean | null
free_cancellation: boolean | null
no_prepayment: boolean | null
rooms_left: number | null
scraped_at: string | null
}>>
}
interface ScheduleInfo {
daily_time: string
today: string
weekday: string
tiers: {
high: { description: string; dates_today: number; range: string | null }
medium: { description: string; dates_today: number; range: string | null }
low: { description: string; dates_today: number; range: string | null }
}
total_dates_today: number
}
interface QueueStatus {
statuses: Record<string, { count: number; earliest: string | null; latest: string | null }>
retries_pending: number
total_pending: number
total_completed: number
total_failed: number
}
interface CoverageEntry {
date: string
tier: 'high' | 'medium' | 'low' | 'none'
last_scraped: string | null
next_expected: string | null
}
interface CoverageResponse {
today: string
coverage: CoverageEntry[]
}
interface ScrapeHistoryEntry {
batch_id: string
scrape_type: string
started_at: string | null
completed_at: string | null
status: string
dates_queued: number | null
dates_completed: number | null
dates_failed: number | null
hotels_found: number | null
rates_scraped: number | null
error_message: string | null
blocked_at: string | null
resume_after: string | null
}
// ============================================
// HELPERS
// ============================================
const formatCurrency = (value: number | null): string => {
if (value === null || value === undefined) return '-'
return new Intl.NumberFormat('en-GB', {
style: 'currency',
currency: 'GBP',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value)
}
const formatDateShort = (dateStr: string): string => {
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString('en-GB', { day: 'numeric' })
}
const formatDayOfWeek = (dateStr: string): string => {
const date = new Date(dateStr + 'T00:00:00')
return date.toLocaleDateString('en-GB', { weekday: 'short' })
}
const isWeekend = (dateStr: string): boolean => {
const date = new Date(dateStr + 'T00:00:00')
const day = date.getDay()
return day === 0 || day === 6
}
const formatDateTime = (iso: string | null): string => {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
}
const formatScrapeAge = (iso: string | null): string => {
if (!iso) return ''
const scraped = new Date(iso)
const now = new Date()
const diffMs = now.getTime() - scraped.getTime()
const diffMins = Math.floor(diffMs / 60000)
if (diffMins < 60) return `${diffMins}m ago`
const diffHours = Math.floor(diffMins / 60)
if (diffHours < 24) return `${diffHours}h ago`
const diffDays = Math.floor(diffHours / 24)
return `${diffDays}d ago`
}
const tierColor = (tier: string) => {
switch (tier) {
case 'own': return '#2563eb'
case 'competitor': return '#d97706'
case 'market': return '#64748b'
default: return '#64748b'
}
}
// ============================================
// INLINE STYLE HELPERS (replacing theme utilities)
// ============================================
const mergeStyles = (...s: React.CSSProperties[]): React.CSSProperties =>
Object.assign({}, ...s)
const buttonStyle = (variant: 'primary' | 'secondary' | 'outline', size?: 'small'): React.CSSProperties => {
const base: React.CSSProperties = {
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: 500,
padding: size === 'small' ? '4px 10px' : '8px 16px',
fontSize: size === 'small' ? '13px' : '14px',
lineHeight: 1.4,
transition: 'all 0.15s',
}
if (variant === 'primary') return { ...base, background: 'var(--gold)', color: '#fff' }
if (variant === 'secondary') return { ...base, background: 'var(--navy)', color: '#fff' }
return { ...base, background: 'transparent', color: 'var(--text-dark)', border: '1px solid var(--card-border)' }
}
const badgeStyle = (variant: 'success' | 'error' | 'warning' | 'info'): React.CSSProperties => {
const map: Record<string, React.CSSProperties> = {
success: { background: '#dcfce7', color: '#16a34a', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
error: { background: '#fee2e2', color: '#dc2626', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
warning: { background: '#fef3c7', color: '#d97706', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
info: { background: '#dbeafe', color: '#2563eb', padding: '2px 8px', borderRadius: '99px', fontSize: '12px', fontWeight: 600 },
}
return map[variant] || map.info
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '8px 10px',
borderRadius: '6px',
border: '1px solid var(--card-border)',
fontSize: '14px',
color: 'var(--text-dark)',
background: 'var(--card-bg)',
boxSizing: 'border-box',
}
const inputLabelStyle: React.CSSProperties = {
display: 'block',
fontSize: '12px',
fontWeight: 600,
color: 'var(--text-mid)',
marginBottom: '4px',
textTransform: 'uppercase',
letterSpacing: '0.04em',
}
// ============================================
// TABS
// ============================================
type TabId = 'matrix' | 'hotels' | 'parity' | 'settings'
// ============================================
// STATUS PANEL
// ============================================
const StatusPanel: React.FC<{ status: ScraperStatus | undefined, isLoading: boolean }> = ({ status, isLoading }) => {
if (isLoading) return <div style={styles.statusBar}>Loading status...</div>
if (!status) return null
return (
<div style={styles.statusBar}>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Scraper</span>
<span style={badgeStyle(status.enabled ? 'success' : 'error')}>
{status.enabled ? 'Enabled' : 'Disabled'}
</span>
</div>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Location</span>
<span style={{ fontSize: '13px', color: status.location_configured ? 'var(--text-dark)' : 'var(--text-mid)' }}>
{status.location_name || 'Not configured'}
</span>
</div>
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Backend</span>
<span style={{ fontSize: '11px', color: 'var(--text-mid)' }}>{status.backend}</span>
</div>
{status.last_scrape && (
<div style={styles.statusItem}>
<span style={styles.statusLabel}>Last Scrape</span>
<span style={badgeStyle(
status.last_scrape.status === 'completed' ? 'success' :
status.last_scrape.status === 'blocked' ? 'warning' :
status.last_scrape.status === 'running' ? 'info' : 'error'
)}>
{status.last_scrape.status}
</span>
<span style={{ fontSize: '11px', color: 'var(--text-mid)' }}>
{formatDateTime(status.last_scrape.completed_at || status.last_scrape.started_at)}
{status.last_scrape.hotels_found ? ` | ${status.last_scrape.hotels_found} hotels, ${status.last_scrape.rates_scraped} rates` : ''}
</span>
</div>
)}
</div>
)
}
// ============================================
// SETTINGS TAB
// ============================================
const SettingsTab: React.FC = () => {
const queryClient = useQueryClient()
const [locationName, setLocationName] = useState('')
const [searchUrl, setSearchUrl] = useState('')
const [pages, setPages] = useState(1)
const [adults, setAdults] = useState(2)
const [scrapeFrom, setScrapeFrom] = useState(() => fmtDate(new Date()))
const [scrapeTo, setScrapeTo] = useState(() => {
const d = new Date()
d.setDate(d.getDate() + 7)
return fmtDate(d)
})
const { data: status } = useQuery<ScraperStatus>({
queryKey: ['scraper-status'],
queryFn: async () => (await api.get('/competitors/status')).data,
})
const { data: history } = useQuery<ScrapeHistoryEntry[]>({
queryKey: ['scrape-history'],
queryFn: async () => (await api.get('/competitors/scrape-history?limit=10')).data,
})
const { data: scheduleInfo } = useQuery<ScheduleInfo>({
queryKey: ['schedule-info'],
queryFn: async () => (await api.get('/competitors/schedule-info')).data,
})
const { data: queueStatus } = useQuery<QueueStatus>({
queryKey: ['queue-status'],
queryFn: async () => (await api.get('/competitors/queue-status')).data,
refetchInterval: 30000,
})
const { data: coverage } = useQuery<CoverageResponse>({
queryKey: ['scrape-coverage'],
queryFn: async () => (await api.get('/competitors/scrape-coverage')).data,
staleTime: 60000,
})
const setLocationMutation = useMutation({
mutationFn: async () => {
return (await api.post('/competitors/config/location', {
location_name: locationName,
location_search_url: searchUrl || undefined,
pages_to_scrape: pages,
adults: adults,
})).data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scraper-status'] })
setLocationName('')
setSearchUrl('')
},
})
const enableMutation = useMutation({
mutationFn: async (enabled: boolean) => {
return (await api.post(`/competitors/config/enable?enabled=${enabled}`)).data
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['scraper-status'] }),
})
const scrapeMutation = useMutation({
mutationFn: async () => {
return (await api.post('/competitors/scrape', {
from_date: scrapeFrom,
to_date: scrapeTo,
})).data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scraper-status'] })
queryClient.invalidateQueries({ queryKey: ['scrape-history'] })
},
})
return (
<div style={styles.settingsGrid}>
{/* Location Configuration */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Location Configuration</h3>
<p style={styles.cardDescription}>
Set the location for competitor rate scraping.
{status?.location_name && (
<> Currently: <strong>{status.location_name}</strong></>
)}
</p>
<div style={styles.formRow}>
<div style={styles.formGroup}>
<label style={inputLabelStyle}>Location Name</label>
<input
type="text"
value={locationName}
onChange={e => setLocationName(e.target.value)}
placeholder="e.g. Bowness-on-Windermere"
style={inputStyle}
/>
</div>
</div>
<div style={{ marginTop: '12px' }}>
<label style={inputLabelStyle}>Booking.com search URL (recommended)</label>
<input
type="text"
value={searchUrl}
onChange={e => setSearchUrl(e.target.value)}
placeholder="https://www.booking.com/searchresults.html?ss=…&dest_id=…"
style={inputStyle}
/>
<p style={{ fontSize: '12px', color: 'var(--text-mid)', margin: '6px 0 0' }}>
Search your area on booking.com and paste the address-bar URL here. It pins the
exact destination (a plain name can resolve to the wrong place). Dates and paging
are set automatically only the destination is read from the URL.
</p>
</div>
<div style={{ ...styles.formRow, marginTop: '12px' }}>
<div style={styles.formGroupSmall}>
<label style={inputLabelStyle}>Pages</label>
<input
type="number"
value={pages}
onChange={e => setPages(parseInt(e.target.value) || 2)}
min={1}
max={5}
style={inputStyle}
/>
</div>
<div style={styles.formGroupSmall}>
<label style={inputLabelStyle}>Adults</label>
<input
type="number"
value={adults}
onChange={e => setAdults(parseInt(e.target.value) || 2)}
min={1}
max={4}
style={inputStyle}
/>
</div>
</div>
<button
onClick={() => setLocationMutation.mutate()}
disabled={(!locationName && !searchUrl) || setLocationMutation.isPending}
style={mergeStyles(
buttonStyle('primary'),
{ marginTop: '16px', opacity: (!locationName && !searchUrl) ? 0.5 : 1 }
)}
>
{setLocationMutation.isPending ? 'Saving...' : 'Set Location'}
</button>
{setLocationMutation.isError && (
<p style={styles.errorText}>
{(setLocationMutation.error as any)?.response?.data?.detail || 'Failed to set location'}
</p>
)}
</div>
{/* Scraper Controls */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Scraper Controls</h3>
<div style={styles.controlRow}>
<button
onClick={() => enableMutation.mutate(!status?.enabled)}
style={buttonStyle(status?.enabled ? 'outline' : 'secondary')}
>
{status?.enabled ? 'Disable Scraper' : 'Enable Scraper'}
</button>
</div>
</div>
{/* Schedule Info */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Automatic Schedule</h3>
{scheduleInfo ? (
<div>
<p style={styles.cardDescription}>
Runs daily at <strong>{scheduleInfo.daily_time}</strong> ({scheduleInfo.weekday})
</p>
<div style={styles.scheduleGrid}>
{Object.entries(scheduleInfo.tiers).map(([key, tier]) => (
<div key={key} style={styles.scheduleTier}>
<div style={styles.scheduleTierHeader}>
<span style={styles.scheduleTierName}>{key}</span>
<span style={badgeStyle(tier.dates_today > 0 ? 'info' : 'warning')}>
{tier.dates_today} dates
</span>
</div>
<p style={styles.scheduleTierDesc}>{tier.description}</p>
{tier.range && (
<p style={styles.scheduleTierRange}>{tier.range}</p>
)}
</div>
))}
</div>
<p style={{ fontSize: '13px', color: 'var(--text-dark)', marginTop: '16px', fontWeight: 600 }}>
Total today: {scheduleInfo.total_dates_today} dates
</p>
</div>
) : (
<p style={styles.noData}>Loading schedule...</p>
)}
{/* Queue Status */}
{queueStatus && (queueStatus.total_pending > 0 || queueStatus.total_failed > 0) && (
<div style={mergeStyles(styles.queuePanel, { marginTop: '16px' })}>
<h4 style={{ margin: '0 0 8px', fontSize: '13px', color: 'var(--text-dark)' }}>Queue</h4>
<div style={styles.queueStats}>
{queueStatus.total_pending > 0 && (
<span style={badgeStyle('info')}>{queueStatus.total_pending} pending</span>
)}
{queueStatus.retries_pending > 0 && (
<span style={badgeStyle('warning')}>{queueStatus.retries_pending} retries</span>
)}
{queueStatus.total_completed > 0 && (
<span style={badgeStyle('success')}>{queueStatus.total_completed} done</span>
)}
{queueStatus.total_failed > 0 && (
<span style={badgeStyle('error')}>{queueStatus.total_failed} failed</span>
)}
</div>
</div>
)}
</div>
{/* Manual Scrape */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Manual Scrape</h3>
<p style={styles.cardDescription}>
Trigger a one-off scrape for a date range. Runs in background.
</p>
<div style={styles.formRow}>
<div style={styles.formGroup}>
<label style={inputLabelStyle}>From Date</label>
<input
type="date"
value={scrapeFrom}
onChange={e => setScrapeFrom(e.target.value)}
style={inputStyle}
/>
</div>
<div style={styles.formGroup}>
<label style={inputLabelStyle}>To Date</label>
<input
type="date"
value={scrapeTo}
onChange={e => setScrapeTo(e.target.value)}
style={inputStyle}
/>
</div>
</div>
<button
onClick={() => scrapeMutation.mutate()}
disabled={scrapeMutation.isPending || !status?.location_configured}
style={mergeStyles(
buttonStyle('primary'),
{ marginTop: '16px', opacity: (!status?.location_configured) ? 0.5 : 1 }
)}
>
{scrapeMutation.isPending ? 'Starting...' : 'Start Scrape'}
</button>
{!status?.location_configured && (
<p style={styles.hintText}>Configure a location first</p>
)}
{scrapeMutation.isSuccess && (
<p style={{ color: 'var(--success)', fontSize: '13px', marginTop: '8px' }}>
Scrape started! Check status for progress.
</p>
)}
{scrapeMutation.isError && (
<p style={styles.errorText}>
{(scrapeMutation.error as any)?.response?.data?.detail || 'Failed to start scrape'}
</p>
)}
</div>
{/* Scrape History */}
<div style={mergeStyles(styles.card, { gridColumn: '1 / -1' })}>
<h3 style={styles.cardTitle}>Scrape History</h3>
{history && history.length > 0 ? (
<div style={styles.historyTable}>
<table style={styles.table}>
<thead>
<tr>
<th style={styles.th}>Type</th>
<th style={styles.th}>Started</th>
<th style={styles.th}>Status</th>
<th style={styles.th}>Hotels</th>
<th style={styles.th}>Rates</th>
<th style={styles.th}>Error</th>
</tr>
</thead>
<tbody>
{history.map(entry => (
<tr key={entry.batch_id}>
<td style={styles.td}>{entry.scrape_type}</td>
<td style={styles.td}>{formatDateTime(entry.started_at)}</td>
<td style={styles.td}>
<span style={badgeStyle(
entry.status === 'completed' ? 'success' :
entry.status === 'blocked' ? 'warning' :
entry.status === 'running' ? 'info' : 'error'
)}>
{entry.status}
</span>
</td>
<td style={styles.td}>{entry.hotels_found ?? '-'}</td>
<td style={styles.td}>{entry.rates_scraped ?? '-'}</td>
<td style={mergeStyles(styles.td, { maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' })}>
{entry.error_message || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p style={styles.noData}>No scrape history yet</p>
)}
</div>
{/* Scrape Coverage - 365 day view */}
<div style={mergeStyles(styles.card, { gridColumn: '1 / -1' })}>
<h3 style={styles.cardTitle}>Scrape Coverage (365 days)</h3>
<p style={styles.cardDescription}>
Each cell is a date. Color shows freshness of data; letter shows priority (H=high, M=medium, L=low).
</p>
{coverage ? <CoverageGrid coverage={coverage} /> : <p style={styles.noData}>Loading coverage...</p>}
</div>
</div>
)
}
// ============================================
// COVERAGE GRID
// ============================================
const freshnessColor = (lastScraped: string | null): React.CSSProperties => {
if (!lastScraped) return { background: '#e8e8e8', color: '#64748b' }
const hours = (Date.now() - new Date(lastScraped).getTime()) / 3600000
if (hours < 24) return { background: '#c6efce', color: '#1a7a2e' } // green - fresh
if (hours < 72) return { background: '#fff3cd', color: '#856404' } // yellow - 1-3 days
if (hours < 168) return { background: '#ffe0b2', color: '#e65100' } // orange - 3-7 days
if (hours < 336) return { background: '#f8d7da', color: '#721c24' } // red - 7-14 days
return { background: '#c62828', color: '#ffffff' } // dark red - >14 days
}
const tierLabel = (tier: string) => {
switch (tier) {
case 'high': return 'H'
case 'medium': return 'M'
case 'low': return 'L'
default: return '-'
}
}
const CoverageGrid: React.FC<{ coverage: CoverageResponse }> = ({ coverage }) => {
// Group by month
const months = useMemo(() => {
const grouped: Record<string, CoverageEntry[]> = {}
for (const entry of coverage.coverage) {
const monthKey = entry.date.substring(0, 7) // YYYY-MM
if (!grouped[monthKey]) grouped[monthKey] = []
grouped[monthKey].push(entry)
}
return Object.entries(grouped)
}, [coverage.coverage])
const formatMonthLabel = (monthKey: string) => {
const [y, m] = monthKey.split('-')
const d = new Date(parseInt(y), parseInt(m) - 1, 1)
return d.toLocaleDateString('en-GB', { month: 'short', year: 'numeric' })
}
const formatDateLabel = (dateStr: string) => {
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' })
}
const formatAge = (iso: string | null): string => {
if (!iso) return 'Never scraped'
const hours = (Date.now() - new Date(iso).getTime()) / 3600000
if (hours < 1) return `${Math.floor(hours * 60)}m ago`
if (hours < 24) return `${Math.floor(hours)}h ago`
return `${Math.floor(hours / 24)}d ago`
}
return (
<div style={styles.coverageContainer}>
{/* Legend */}
<div style={styles.coverageLegend}>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#c6efce' })}>{'<'}24h</span>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#fff3cd' })}>1-3d</span>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#ffe0b2' })}>3-7d</span>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#f8d7da' })}>7-14d</span>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#c62828', color: '#fff' })}>{'>'} 14d</span>
<span style={mergeStyles(styles.coverageLegendItem, { background: '#e8e8e8' })}>Never</span>
<span style={{ marginLeft: '16px', fontSize: '11px', color: 'var(--text-mid)' }}>
H=High M=Medium L=Low priority
</span>
</div>
{months.map(([monthKey, entries]) => (
<div key={monthKey} style={styles.coverageMonth}>
<div style={styles.coverageMonthLabel}>{formatMonthLabel(monthKey)}</div>
<div style={styles.coverageCells}>
{entries.map(entry => (
<div
key={entry.date}
style={mergeStyles(styles.coverageCell, freshnessColor(entry.last_scraped))}
title={`${formatDateLabel(entry.date)}\nTier: ${entry.tier}\nLast: ${formatAge(entry.last_scraped)}\nNext: ${entry.next_expected || 'N/A'}`}
>
<span style={styles.coverageCellDay}>
{new Date(entry.date + 'T00:00:00').getDate()}
</span>
<span style={styles.coverageCellTier}>
{tierLabel(entry.tier)}
</span>
</div>
))}
</div>
</div>
))}
</div>
)
}
// ============================================
// HOTELS TAB
// ============================================
// ============================================
// PARITY ALERTS TAB
// ============================================
interface ParityAlert {
id: number
rate_date: string
room_category: string | null
newbook_rate: number | null
booking_com_rate: number | null
difference_pct: number | null
alert_type: string
alert_status: string
created_at: string | null
acknowledged_at: string | null
acknowledged_by: string | null
}
const ParityAlertsTab: React.FC = () => {
const queryClient = useQueryClient()
const [statusFilter, setStatusFilter] = useState<string>('active')
const { data: alerts, isLoading } = useQuery<ParityAlert[]>({
queryKey: ['parity-alerts', statusFilter],
queryFn: async () => {
const params = statusFilter ? `?status=${statusFilter}` : ''
return (await api.get(`/competitors/parity/alerts${params}`)).data
},
})
const { data: parityConfig } = useQuery<Record<string, string | null>>({
queryKey: ['system-config'],
queryFn: async () => (await api.get('/competitors/config/system')).data,
})
const ackMutation = useMutation({
mutationFn: async (alertId: number) =>
(await api.put(`/competitors/parity/alerts/${alertId}/acknowledge`)).data,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['parity-alerts'] })
queryClient.invalidateQueries({ queryKey: ['parity-alert-count'] })
},
})
const markupUnit = parityConfig?.['parity_markup_unit'] ?? 'pct'
const toleranceUnit = parityConfig?.['parity_tolerance_unit'] ?? 'pct'
const markupVal = parityConfig?.['parity_markup_value'] ?? parityConfig?.['parity_expected_markup_pct'] ?? '0'
const toleranceVal = parityConfig?.['parity_tolerance_value'] ?? parityConfig?.['parity_tolerance_pct'] ?? '2'
const markupLabel = markupUnit === 'gbp' ? `£${markupVal}` : `${markupVal}%`
const toleranceLabel = toleranceUnit === 'gbp' ? `£${toleranceVal}` : `${toleranceVal}%`
const thStyle: React.CSSProperties = {
textAlign: 'left', padding: '8px 12px', fontSize: 11, fontWeight: 600,
color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.04em',
borderBottom: '1px solid var(--border)',
}
const tdStyle: React.CSSProperties = {
padding: '9px 12px', fontSize: 13, borderBottom: '1px solid var(--border)',
}
return (
<div>
<div style={{ fontSize: 12, color: 'var(--text-mid)', marginBottom: 14, maxWidth: 720 }}>
Dates where our Booking.com rate deviates from the expected level
(Newbook rate + {markupLabel} markup, ±{toleranceLabel} tolerance). Checked daily at 06:45
adjust the markup and tolerance in Settings Rate Parity. Acknowledge a date once
dealt with; alerts auto-resolve when the rates come back in line.
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 14, alignItems: 'center' }}>
<span style={{ fontSize: 12, color: 'var(--text-mid)' }}>Status:</span>
{['active', 'acknowledged', 'resolved', ''].map(s => (
<button
key={s}
onClick={() => setStatusFilter(s)}
style={mergeStyles(
buttonStyle(statusFilter === s ? 'secondary' : 'outline', 'small'),
statusFilter === s ? {} : { opacity: 0.7 }
)}
>
{s || 'All'}
</button>
))}
</div>
{isLoading && (
<div style={styles.loading}><div style={styles.spinner} /><span>Loading alerts...</span></div>
)}
{!isLoading && (!alerts || alerts.length === 0) && (
<div style={styles.emptyState}>
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No {statusFilter || ''} parity alerts</h3>
<p style={{ color: 'var(--text-mid)', margin: '8px 0 0' }}>
{statusFilter === 'active'
? 'Booking.com is pricing within the expected band of Newbook.'
: 'Nothing here yet.'}
</p>
</div>
)}
{!isLoading && alerts && alerts.length > 0 && (
<div style={{ background: 'var(--white, #fff)', border: '1px solid var(--border)', borderRadius: 10, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Date</th>
<th style={thStyle}>Newbook</th>
<th style={thStyle}>Booking.com</th>
<th style={thStyle}>Deviation</th>
<th style={thStyle}>Room</th>
<th style={thStyle}>Status</th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{alerts.map(a => (
<tr key={a.id}>
<td style={mergeStyles(tdStyle, { fontWeight: 600 })}>{a.rate_date}</td>
<td style={tdStyle}>{a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'}</td>
<td style={tdStyle}>{a.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'}</td>
<td style={tdStyle}>
{a.difference_pct != null && (() => {
// expected = booking / (1 + dev%); £ deviation derived from that
const expected = a.booking_com_rate != null
? a.booking_com_rate / (1 + a.difference_pct / 100)
: null
const devGbp = expected != null && a.booking_com_rate != null
? a.booking_com_rate - expected
: null
return (
<span style={badgeStyle(a.alert_type === 'higher' ? 'warning' : 'error')}>
{a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}%
{devGbp != null ? ` (${devGbp > 0 ? '+' : ''}£${Math.abs(devGbp).toFixed(2)})` : ''} vs expected
</span>
)
})()}
</td>
<td style={mergeStyles(tdStyle, { color: 'var(--text-mid)', fontSize: 12 })}>{a.room_category || '—'}</td>
<td style={tdStyle}>
<span style={badgeStyle(
a.alert_status === 'active' ? 'error'
: a.alert_status === 'acknowledged' ? 'info' : 'success'
)}>
{a.alert_status}
</span>
</td>
<td style={tdStyle}>
{a.alert_status === 'active' && (
<button
style={buttonStyle('outline', 'small')}
disabled={ackMutation.isPending}
onClick={() => ackMutation.mutate(a.id)}
>
Acknowledge
</button>
)}
{a.alert_status === 'acknowledged' && a.acknowledged_by && (
<span style={{ fontSize: 11, color: 'var(--text-mid)' }}>by {a.acknowledged_by}</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
const HotelsTab: React.FC = () => {
const queryClient = useQueryClient()
const [tierFilter, setTierFilter] = useState<string>('')
const { data: hotels, isLoading } = useQuery<Hotel[]>({
queryKey: ['competitor-hotels', tierFilter],
queryFn: async () => {
const params = tierFilter ? `?tier=${tierFilter}` : ''
return (await api.get(`/competitors/hotels${params}`)).data
},
})
const tierMutation = useMutation({
mutationFn: async ({ hotelId, tier }: { hotelId: number, tier: string }) => {
return (await api.put(`/competitors/hotels/${hotelId}/tier`, { tier })).data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['competitor-hotels'] })
},
})
// Direct booking-engine hotels, for linking (powers the matrix direct-rates sub-row)
const { data: directHotels } = useQuery<{ id: number; name: string }[]>({
queryKey: ['direct-hotels-link-options'],
queryFn: async () => (await api.get('/direct/hotels')).data,
retry: false,
})
const linkMutation = useMutation({
mutationFn: async ({ hotelId, directId }: { hotelId: number, directId: number | null }) => {
return (await api.put(`/competitors/hotels/${hotelId}/direct-link`, { direct_hotel_id: directId })).data
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['competitor-hotels'] })
queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] })
queryClient.invalidateQueries({ queryKey: ['market-direct-rates'] })
},
})
const grouped = useMemo(() => {
if (!hotels) return { own: [], competitor: [], market: [] }
return {
own: hotels.filter(h => h.tier === 'own'),
competitor: hotels.filter(h => h.tier === 'competitor'),
market: hotels.filter(h => h.tier === 'market'),
}
}, [hotels])
const HotelCard: React.FC<{ hotel: Hotel }> = ({ hotel }) => (
<div style={mergeStyles(styles.hotelCard, { borderLeft: `4px solid ${tierColor(hotel.tier)}` })}>
<div style={styles.hotelHeader}>
<div style={styles.hotelInfo}>
<span style={styles.hotelName}>{hotel.name}</span>
<div style={styles.hotelMeta}>
{hotel.star_rating && <span>{hotel.star_rating} stars</span>}
{hotel.review_score && <span>Score: {hotel.review_score}</span>}
{hotel.review_count && <span>({hotel.review_count} reviews)</span>}
</div>
</div>
<div style={styles.hotelActions}>
{hotel.tier === 'competitor' && directHotels && directHotels.length > 0 && (
<select
value={hotel.direct_hotel_id ?? ''}
onChange={e => linkMutation.mutate({
hotelId: hotel.id,
directId: e.target.value ? parseInt(e.target.value) : null,
})}
title="Link to a direct booking-engine competitor to show their direct rates in the matrix"
style={mergeStyles(styles.tierSelect, { maxWidth: 190 })}
>
<option value="">No direct link</option>
{directHotels.map(d => (
<option key={d.id} value={d.id}>Direct: {d.name}</option>
))}
</select>
)}
<select
value={hotel.tier}
onChange={e => tierMutation.mutate({ hotelId: hotel.id, tier: e.target.value })}
style={mergeStyles(styles.tierSelect, { borderColor: tierColor(hotel.tier) })}
>
<option value="own">Own Hotel</option>
<option value="competitor">Competitor</option>
<option value="market">Market</option>
</select>
</div>
</div>
<div style={styles.hotelFooter}>
<span style={{ fontSize: '11px', color: 'var(--text-mid)' }}>
ID: {hotel.booking_com_id}
</span>
{hotel.last_seen_at && (
<span style={{ fontSize: '11px', color: 'var(--text-mid)' }}>
Last seen: {formatDateTime(hotel.last_seen_at)}
</span>
)}
</div>
</div>
)
if (isLoading) {
return (
<div style={styles.loading}>
<div style={styles.spinner} />
<span>Loading hotels...</span>
</div>
)
}
return (
<div>
{/* Filter */}
<div style={styles.filterRow}>
<span style={styles.filterLabel}>Filter:</span>
{['', 'own', 'competitor', 'market'].map(t => (
<button
key={t}
onClick={() => setTierFilter(t)}
style={mergeStyles(
buttonStyle(tierFilter === t ? 'secondary' : 'outline', 'small'),
tierFilter === t ? {} : { opacity: 0.7 }
)}
>
{t || 'All'} {t && hotels ? `(${grouped[t as keyof typeof grouped]?.length || 0})` : hotels ? `(${hotels.length})` : ''}
</button>
))}
</div>
{/* Hotels */}
{!hotels || hotels.length === 0 ? (
<div style={styles.emptyState}>
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No Hotels Discovered</h3>
<p style={{ color: 'var(--text-mid)', margin: '8px 0 0' }}>
Run a scrape to discover hotels in your configured location.
</p>
</div>
) : (
<div>
{/* Own Hotel */}
{grouped.own.length > 0 && (
<div style={styles.tierSection}>
<h3 style={mergeStyles(styles.tierHeader, { color: '#2563eb' })}>
Your Hotel ({grouped.own.length})
</h3>
{grouped.own.map(h => <HotelCard key={h.id} hotel={h} />)}
</div>
)}
{/* Competitors */}
{grouped.competitor.length > 0 && (
<div style={styles.tierSection}>
<h3 style={mergeStyles(styles.tierHeader, { color: '#d97706' })}>
Competitors ({grouped.competitor.length})
</h3>
{grouped.competitor.map(h => <HotelCard key={h.id} hotel={h} />)}
</div>
)}
{/* Market */}
{grouped.market.length > 0 && (
<div style={styles.tierSection}>
<h3 style={mergeStyles(styles.tierHeader, { color: 'var(--text-mid)' })}>
Market ({grouped.market.length})
</h3>
{grouped.market.map(h => <HotelCard key={h.id} hotel={h} />)}
</div>
)}
</div>
)}
</div>
)
}
// ============================================
// RATE MATRIX TAB
// ============================================
const MonthSelector: React.FC<{
value: string
onChange: (value: string) => void
}> = ({ value, onChange }) => {
const handlePrevMonth = () => {
const [year, month] = value.split('-').map(Number)
const date = new Date(year, month - 2, 1)
onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`)
}
const handleNextMonth = () => {
const [year, month] = value.split('-').map(Number)
const date = new Date(year, month, 1)
onChange(`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`)
}
const monthOptions = useMemo(() => {
const options: { value: string; label: string }[] = []
const now = new Date()
for (let i = 0; i < 13; i++) {
const date = new Date(now.getFullYear(), now.getMonth() + i, 1)
const monthValue = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
const label = date.toLocaleDateString('en-GB', { month: 'long', year: 'numeric' })
options.push({ value: monthValue, label })
}
return options
}, [])
return (
<div style={styles.monthSelector}>
<button onClick={handlePrevMonth} style={buttonStyle('outline', 'small')}>
&larr;
</button>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={styles.monthDropdown}
>
{monthOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button onClick={handleNextMonth} style={buttonStyle('outline', 'small')}>
&rarr;
</button>
</div>
)
}
// ============================================
// RATE HISTORY MODAL
// ============================================
interface RateHistoryPoint { t: string | null; rate: number }
interface RateHistorySeries { room_type: string; points: RateHistoryPoint[] }
interface RateHistoryModal { hotelId: number; hotelName: string; stayDate: string }
interface RatePlan { meal: string; cancel: string; price: number; max_persons: number | null }
interface RoomSnapshot { room_type: string; rooms_left: number | null; availability_status: string; plans: RatePlan[] }
interface RateSnapshot { stay_date: string; legacy: boolean; rooms: RoomSnapshot[] }
const CHART_COLORS = ['#1a1a2e', '#c9a84c', '#16a34a', '#dc2626', '#7c3aed', '#0891b2', '#ea580c', '#be185d']
const RateHistoryModalComponent: React.FC<{ modal: RateHistoryModal; onClose: () => void }> = ({ modal, onClose }) => {
const { data: histData, isLoading: histLoading } = useQuery<{ stay_date: string; series: RateHistorySeries[] }>({
queryKey: ['hotel-rate-history', modal.hotelId, modal.stayDate],
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-history/${modal.stayDate}`)).data,
})
const { data: snap, isLoading: snapLoading } = useQuery<RateSnapshot>({
queryKey: ['hotel-rate-snapshot', modal.hotelId, modal.stayDate],
queryFn: async () => (await api.get(`/competitors/hotels/${modal.hotelId}/rate-snapshot/${modal.stayDate}`)).data,
})
const traces = useMemo(() => {
if (!histData?.series) return []
return histData.series.map((s, i) => ({
type: 'scatter' as const,
mode: 'lines+markers' as const,
name: s.room_type,
x: s.points.map(p => p.t),
y: s.points.map(p => p.rate),
line: { color: CHART_COLORS[i % CHART_COLORS.length], width: 2 },
marker: { size: 5 },
hovertemplate: `£%{y:.0f}<br>%{x}<extra>${s.room_type}</extra>`,
}))
}, [histData])
const isLoading = histLoading || snapLoading
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, width: '90%', maxWidth: 820, maxHeight: '90vh', overflow: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-dark)' }}>{modal.hotelName}</div>
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginTop: 2 }}>{modal.stayDate}</div>
</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-mid)', padding: 4 }}>
<X size={18} strokeWidth={1.75} />
</button>
</div>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-mid)' }}>Loading</div>
) : (
<>
{/* Current availability snapshot */}
{snap && snap.rooms.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
Current availability
</div>
{snap.rooms.map(room => (
<div key={room.room_type} style={{ marginBottom: 12, border: '1px solid var(--border)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: 'var(--body-bg)', padding: '8px 12px' }}>
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-dark)' }}>{room.room_type}</span>
{room.availability_status === 'sold_out' ? (
<span style={{ fontSize: 11, color: '#dc2626', fontWeight: 500 }}>Sold out</span>
) : room.rooms_left != null ? (
<span style={{ fontSize: 11, color: room.rooms_left <= 2 ? '#dc2626' : '#ea580c', fontWeight: 500 }}>
{room.rooms_left} left
</span>
) : null}
</div>
{room.plans.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<tbody>
{room.plans.map((plan, pi) => (
<tr key={pi} style={{ borderTop: '1px solid var(--border)' }}>
<td style={{ padding: '6px 12px', color: 'var(--text-mid)' }}>{plan.meal}</td>
<td style={{ padding: '6px 12px', color: plan.cancel.startsWith('Free') ? '#16a34a' : 'var(--text-mid)' }}>
{plan.cancel}
</td>
<td style={{ padding: '6px 12px', textAlign: 'right', fontWeight: 600, color: 'var(--text-dark)' }}>
£{plan.price.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
{room.plans.length === 0 && room.availability_status !== 'sold_out' && (
<div style={{ padding: '6px 12px', fontSize: 12, color: 'var(--text-mid)' }}>No rates available</div>
)}
</div>
))}
</div>
)}
{snap && snap.rooms.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--text-mid)', marginBottom: 20, padding: '12px', background: 'var(--body-bg)', borderRadius: 8 }}>
No rate data from latest scrape for this date.
</div>
)}
{/* Rate history chart */}
{histData?.series && histData.series.length > 0 && (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-mid)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 10 }}>
Rate history best available per room type
</div>
<Plot
data={traces}
layout={{
margin: { t: 10, r: 10, b: 50, l: 55 },
height: 300,
legend: { orientation: 'h', y: -0.25 },
xaxis: { title: { text: '' }, tickformat: '%d %b %H:%M', type: 'date' },
yaxis: { title: { text: 'Rate (£)' }, tickprefix: '£', tickformat: ',.0f' },
plot_bgcolor: 'var(--body-bg)',
paper_bgcolor: 'var(--card-bg)',
font: { family: 'inherit', size: 11, color: 'var(--text-dark)' },
hovermode: 'x unified',
}}
config={{ displayModeBar: false, responsive: true }}
style={{ width: '100%' }}
/>
</>
)}
{histData?.series && histData.series.length === 0 && (
<div style={{ fontSize: 13, color: 'var(--text-mid)', padding: '12px', background: 'var(--body-bg)', borderRadius: 8 }}>
No historical rate data yet history builds up over time as daily scrapes run.
</div>
)}
</>
)}
</div>
</div>
)
}
const RateMatrixTab: React.FC = () => {
const [selectedMonth, setSelectedMonth] = useState(() => {
const today = new Date()
return `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}`
})
const [includeMarket, setIncludeMarket] = useState(false)
const [hoveredCell, setHoveredCell] = useState<{ row: number; col: number } | null>(null)
const [rangeMode, setRangeMode] = useState(false)
const [customFrom, setCustomFrom] = useState(fmtDate(new Date()))
const [customTo, setCustomTo] = useState(fmtDate(new Date(Date.now() + 13 * 86400000)))
const [showDirect, setShowDirect] = useState(false)
const [historyModal, setHistoryModal] = useState<RateHistoryModal | null>(null)
const queryClient = useQueryClient()
const todayStr = fmtDate(new Date())
// The scrape endpoint returns immediately (runs in background), and the
// scraper handles one job at a time, so queued jobs (single dates or
// ranges) are dispatched sequentially: watch scraper status until a new
// batch finishes, refetch the matrix, then start the next job.
const [scrapeQueue, setScrapeQueue] = useState<ScrapeJob[]>([])
const [scrapeWatch, setScrapeWatch] = useState<{ prevBatch: string | null, startedAt: number, timeoutMs: number } | null>(null)
const [dispatchHoldUntil, setDispatchHoldUntil] = useState(0)
const [retryTick, setRetryTick] = useState(0)
const enqueueScrape = (from: string, to: string) => {
setScrapeQueue(q => q.some(j => j.from === from && j.to === to) ? q : [...q, { from, to }])
}
const activeJob = scrapeWatch ? scrapeQueue[0] : undefined
const scrapingDate = activeJob && activeJob.from === activeJob.to ? activeJob.from : null
const dateScrapeM = useMutation({
mutationFn: async (job: ScrapeJob) => {
const st = (await api.get('/competitors/status')).data as ScraperStatus
const days = Math.round((new Date(job.to).getTime() - new Date(job.from).getTime()) / 86400000) + 1
setScrapeWatch({
prevBatch: st?.last_scrape?.batch_id ?? null,
startedAt: Date.now(),
// generous per-date allowance: ranges take ~1 min per date
timeoutMs: 10 * 60 * 1000 + days * 90 * 1000,
})
return (await api.post('/competitors/scrape', { from_date: job.from, to_date: job.to })).data
},
onError: (err: any) => {
setScrapeWatch(null)
if (err?.response?.status === 409) {
// Server is busy with another scrape — keep the job queued, retry in 30s
setDispatchHoldUntil(Date.now() + 30000)
setTimeout(() => setRetryTick(t => t + 1), 31000)
} else {
setScrapeQueue(q => q.slice(1))
}
},
})
// Dispatch the next queued job when idle
useEffect(() => {
if (Date.now() < dispatchHoldUntil) return
if (scrapeQueue.length && !scrapeWatch && !dateScrapeM.isPending) {
dateScrapeM.mutate(scrapeQueue[0])
}
}, [scrapeQueue, scrapeWatch, dateScrapeM.isPending, dispatchHoldUntil, retryTick]) // eslint-disable-line react-hooks/exhaustive-deps
// Also serves the date-header Booking.com links via location_name
const { data: watchStatus } = useQuery<ScraperStatus>({
queryKey: ['scraper-status'],
queryFn: async () => (await api.get('/competitors/status')).data,
refetchInterval: scrapeWatch !== null ? 5000 : false,
staleTime: 60 * 1000,
})
const locationName = watchStatus?.location_name
useEffect(() => {
if (!scrapeWatch) return
const ls = watchStatus?.last_scrape
const finished = ls && ls.batch_id !== scrapeWatch.prevBatch && ls.status !== 'running'
const timedOut = Date.now() - scrapeWatch.startedAt > scrapeWatch.timeoutMs
if (finished || timedOut) {
queryClient.invalidateQueries({ queryKey: ['competitor-matrix'] })
queryClient.invalidateQueries({ queryKey: ['scraper-status'] })
setScrapeWatch(null)
setScrapeQueue(q => q.slice(1))
}
}, [watchStatus, scrapeWatch, queryClient])
const onCellEnter = useCallback((row: number, col: number) => {
setHoveredCell({ row, col })
}, [])
const onCellLeave = useCallback(() => setHoveredCell(null), [])
const { fromDate, toDate } = useMemo(() => {
if (rangeMode) return { fromDate: customFrom, toDate: customTo }
const [year, month] = selectedMonth.split('-').map(Number)
return {
fromDate: fmtDate(new Date(year, month - 1, 1)),
toDate: fmtDate(new Date(year, month, 0)),
}
}, [selectedMonth, rangeMode, customFrom, customTo])
const { data, isLoading, error } = useQuery<RateMatrixResponse>({
queryKey: ['competitor-matrix', fromDate, toDate, includeMarket],
queryFn: async () => {
const params = new URLSearchParams({
from_date: fromDate,
to_date: toDate,
include_market: includeMarket.toString(),
})
return (await api.get(`/competitors/matrix?${params}`)).data
},
// While a scrape runs, refresh so columns fill in as dates complete
refetchInterval: scrapeWatch !== null ? 30000 : false,
})
const dates = data?.dates || []
const rates = data?.rates || {}
// Sort hotels: own first, then competitor, then market
const hotels = useMemo(() => {
const tierPriority: Record<string, number> = { own: 0, competitor: 1, market: 2 }
return [...(data?.hotels || [])].sort((a, b) => {
const ta = tierPriority[a.tier] ?? 9
const tb = tierPriority[b.tier] ?? 9
if (ta !== tb) return ta - tb
return (a.display_order ?? 999) - (b.display_order ?? 999)
})
}, [data?.hotels])
// Latest scrape touching each date column, across ALL hotels (the backend
// map covers hotels outside the matrix too — a partial scrape can refresh
// a date without touching the displayed hotels). Fallback: compute from
// the visible cells.
const scrapedAtByDate = useMemo(() => {
const result: Record<string, string | null> = {}
for (const d of dates) {
let latest: string | null = data?.last_scraped?.[d] ?? null
for (const hotel of hotels) {
const rate = (rates[hotel.id] || {})[d]
if (rate?.scraped_at) {
if (!latest || rate.scraped_at > latest) {
latest = rate.scraped_at
}
}
}
result[d] = latest
}
return result
}, [dates, hotels, rates, data?.last_scraped])
// Own hotel rate by date (first 'own' tier hotel)
const ownRateByDate = useMemo(() => {
const ownHotel = hotels.find(h => h.tier === 'own')
if (!ownHotel) return {} as Record<string, number | null>
const result: Record<string, number | null> = {}
for (const d of dates) {
const r = (rates[ownHotel.id] || {})[d]
result[d] = r?.rate_gross ?? null
}
return result
}, [hotels, rates, dates])
// Direct rates per competitor hotel per date (cheapest)
const { data: directRatesMap } = useQuery<Record<number, Record<string, number | null>>>({
queryKey: ['market-direct-rates', fromDate, toDate],
queryFn: async () => {
const compHotels = hotels.filter(h => h.tier === 'competitor' && h.direct_hotel_id)
if (!compHotels.length) return {}
const result: Record<number, Record<string, number | null>> = {}
await Promise.all(compHotels.map(async h => {
const directId = h.direct_hotel_id!
try {
const res = await api.get(`/direct/hotels/${directId}/dates`, {
params: { from_date: fromDate, to_date: toDate }
})
result[h.id] = Object.fromEntries(
(res.data.dates as any[]).map((row: any) => [row.stay_date, row.cheapest_rate])
)
} catch { result[h.id] = {} }
}))
return result
},
enabled: showDirect && hotels.length > 0,
})
// Our own direct rate comes from Newbook (best available), not the direct scraper
const { data: ownDirectRates } = useQuery<Record<string, { rate: number; tariff: string }>>({
queryKey: ['own-direct-rates', fromDate, toDate],
queryFn: async () => {
const res = await api.get('/competitors/own-direct-rates', {
params: { from_date: fromDate, to_date: toDate }
})
return res.data.rates
},
enabled: showDirect,
})
if (isLoading) {
return (
<div style={styles.loading}>
<div style={styles.spinner} />
<span>Loading rate matrix...</span>
</div>
)
}
if (error) {
return (
<div style={styles.errorBox}>
{(error as any)?.response?.data?.detail || 'Failed to load rate matrix'}
</div>
)
}
return (
<div>
{/* Controls */}
<div style={{ ...styles.matrixControls, flexWrap: 'wrap', gap: 12 }}>
{!rangeMode ? (
<MonthSelector value={selectedMonth} onChange={setSelectedMonth} />
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input type="date" style={{ ...inputStyle, width: 136 }} value={customFrom}
onChange={e => setCustomFrom(e.target.value)} />
<span style={{ color: 'var(--text-mid)', fontSize: 12 }}>to</span>
<input type="date" style={{ ...inputStyle, width: 136 }} value={customTo}
onChange={e => setCustomTo(e.target.value)} />
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
{[{ label: '7d', days: 7 }, { label: '14d', days: 14 }, { label: '30d', days: 30 }, { label: '90d', days: 90 }].map(p => (
<button key={p.label}
style={buttonStyle('outline', 'small')}
onClick={() => {
setRangeMode(true)
setCustomFrom(fmtDate(new Date()))
setCustomTo(fmtDate(new Date(Date.now() + p.days * 86400000)))
}}
>
{p.label}
</button>
))}
{rangeMode && (
<button style={buttonStyle('secondary', 'small')} onClick={() => setRangeMode(false)}>
Month view
</button>
)}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginLeft: 'auto' }}>
{[{ label: 'Scrape 7d', days: 7 }, { label: 'Scrape 30d', days: 30 }].map(p => {
const from = fmtDate(new Date())
const to = fmtDate(new Date(Date.now() + (p.days - 1) * 86400000))
const queued = scrapeQueue.some(j => j.from === from && j.to === to)
const active = activeJob && activeJob.from === from && activeJob.to === to
return (
<button key={p.label}
style={{ ...buttonStyle('primary', 'small'), opacity: queued ? 0.7 : 1, display: 'inline-flex', alignItems: 'center', gap: 5 }}
disabled={queued}
onClick={() => enqueueScrape(from, to)}
title={`Scrape today + next ${p.days - 1} days (~${p.days} min)`}
>
<RefreshCw size={13} strokeWidth={1.75} style={active ? { animation: 'spin 1.5s linear infinite' } : undefined} />
{active ? 'Scraping…' : queued ? 'Queued' : p.label}
</button>
)
})}
</div>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={includeMarket}
onChange={e => setIncludeMarket(e.target.checked)}
/>
Include market hotels
</label>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={showDirect}
onChange={e => setShowDirect(e.target.checked)}
/>
Show direct rates
</label>
</div>
{hotels.length === 0 ? (
<div style={styles.emptyState}>
<h3 style={{ margin: 0, color: 'var(--text-dark)' }}>No Rate Data</h3>
<p style={{ color: 'var(--text-mid)', margin: '8px 0 0' }}>
Run a scrape and categorize hotels as competitors to see rate comparisons.
</p>
</div>
) : (
<div style={styles.matrixContainer}>
<table style={styles.matrixTable}>
<thead>
<tr>
<th style={mergeStyles(styles.matrixTh, styles.stickyCol)}>Hotel</th>
{dates.map((d, colIdx) => {
const scrapeAge = formatScrapeAge(scrapedAtByDate[d])
const isColHovered = hoveredCell?.col === colIdx
const queuePos = scrapeQueue.findIndex(j => j.from === d && j.to === d)
return (
<th
key={d}
style={mergeStyles(
styles.matrixTh,
styles.dateHeader,
isWeekend(d) ? styles.weekendHeader : {},
isColHovered ? styles.crosshairCol : {}
)}
title={scrapedAtByDate[d] ? `Scraped: ${new Date(scrapedAtByDate[d]!).toLocaleString('en-GB')}` : 'No data scraped'}
>
<div style={styles.dateHeaderContent}>
<span style={styles.dayOfWeek}>{formatDayOfWeek(d)}</span>
<span style={styles.dayNum}>{formatDateShort(d)}</span>
{scrapeAge ? (
<span style={styles.scrapeAge}>{scrapeAge}</span>
) : null}
<div style={{ display: 'flex', gap: 2, alignItems: 'center', justifyContent: 'center' }}>
<button
onClick={() => enqueueScrape(d, d)}
disabled={queuePos >= 0}
style={mergeStyles(
styles.scrapeBtn,
queuePos >= 0 ? styles.scrapeBtnActive : {}
)}
title={
scrapingDate === d ? `Scraping ${d}`
: queuePos >= 0 ? `Queued (#${queuePos + 1})`
: `Scrape ${d}`
}
>
{scrapingDate === d ? '...' : queuePos >= 0 ? `#${queuePos + 1}` : '↻'}
</button>
{locationName && d >= todayStr && (
<a
href={buildSearchUrl(locationName, d)}
target="_blank"
rel="noopener noreferrer"
title={`View ${d} search on Booking.com`}
style={{ color: 'var(--text-mid)', display: 'inline-flex', alignItems: 'center' }}
>
<Eye size={11} strokeWidth={1.75} />
</a>
)}
</div>
</div>
</th>
)
})}
</tr>
</thead>
<tbody>
{hotels.map((hotel, rowIdx) => {
const hotelRates = rates[hotel.id] || {}
const isRowHovered = hoveredCell?.row === rowIdx
return (
<React.Fragment key={hotel.id}>
<tr>
<td style={mergeStyles(
styles.matrixTd, styles.stickyCol, styles.hotelNameCell,
isRowHovered ? styles.crosshairRow : {}
)}>
<div style={styles.matrixHotelInfo}>
<span
style={mergeStyles(styles.tierDot, { background: tierColor(hotel.tier) })}
/>
<span style={styles.matrixHotelName}>{hotel.name}</span>
{hotel.star_rating && (
<span style={styles.matrixStars}>{hotel.star_rating}*</span>
)}
</div>
</td>
{dates.map((d, colIdx) => {
const rate = hotelRates[d]
const isAvailable = rate?.availability_status === 'available'
const isSoldOut = rate?.availability_status === 'sold_out'
const isPast = d < todayStr
const isSoldOrPast = isSoldOut || isPast
let cellStyle: React.CSSProperties = styles.matrixCellEmpty
if (rate) {
if (isPast) {
cellStyle = styles.matrixCellPast
} else if (isAvailable && rate.rate_gross) {
cellStyle = styles.matrixCellAvailable
} else if (isSoldOut) {
cellStyle = styles.matrixCellSoldOut
} else {
cellStyle = styles.matrixCellNoRate
}
}
// Stale = this cell wasn't touched by the column's most
// recent scrape (e.g. the hotel was on a page that failed)
const colLatest = scrapedAtByDate[d]
const isStale = !isPast && !!(rate?.scraped_at && colLatest &&
new Date(colLatest).getTime() - new Date(rate.scraped_at).getTime() > 60 * 60 * 1000)
const staleMark = isStale ? '*' : ''
const lastAvailRate = rate?.last_available_rate
? formatCurrency(rate.last_available_rate)
: null
// For sold-out / past: show last known available rate with strikethrough
let rateText = ''
let strikethrough = false
if (!rate) {
rateText = ''
} else if (isSoldOrPast && lastAvailRate) {
rateText = lastAvailRate + staleMark
strikethrough = true
} else if (isSoldOrPast) {
rateText = isSoldOut ? 'Sold' : '—'
} else if (isAvailable && rate.rate_gross) {
rateText = formatCurrency(rate.rate_gross) + staleMark
} else {
rateText = '—'
}
const tooltip = rate ? [
rate.room_type,
rate.breakfast_included ? 'Breakfast incl.' : null,
rate.free_cancellation ? 'Free cancel' : null,
rate.rooms_left ? `${rate.rooms_left} left` : null,
lastAvailRate && isSoldOrPast ? `Last available: ${lastAvailRate}` : null,
rate.scraped_at ? `Scraped: ${new Date(rate.scraped_at).toLocaleString('en-GB', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}` : null,
isStale ? 'Not updated in latest scrape' : null,
].filter(Boolean).join(' | ') : ''
// Build booking.com link
let bookingUrl: string | null = null
if (!isPast && hotel.booking_com_url) {
const checkin = d
const co = new Date(d + 'T00:00:00')
co.setDate(co.getDate() + 1)
const checkout = fmtDate(co)
try {
const url = new URL(hotel.booking_com_url)
const stripParams = ['checkin', 'checkout', 'group_adults', 'group_children', 'req_adults', 'req_children', 'no_rooms']
stripParams.forEach(p => url.searchParams.delete(p))
url.searchParams.set('checkin', checkin)
url.searchParams.set('checkout', checkout)
url.searchParams.set('group_adults', '2')
bookingUrl = url.toString()
} catch {
bookingUrl = hotel.booking_com_url
}
}
// Price index badge for competitor rows (use last_available_rate for sold/past)
const rateForIndex = isSoldOrPast ? rate?.last_available_rate : rate?.rate_gross
let priceIndexBadge: React.ReactNode = null
if (hotel.tier === 'competitor' && rateForIndex && ownRateByDate[d]) {
const delta = Math.round((rateForIndex / ownRateByDate[d]! - 1) * 100)
const bg = delta > 5 ? '#dcfce7' : delta < -15 ? '#fee2e2' : delta < -5 ? '#fef3c7' : '#f1f5f9'
const fg = delta > 5 ? '#16a34a' : delta < -15 ? '#dc2626' : delta < -5 ? '#d97706' : '#64748b'
priceIndexBadge = (
<span style={{ fontSize: 9, fontWeight: 700, color: fg, background: bg,
borderRadius: 4, padding: '0 3px', lineHeight: '14px' }}>
{delta > 0 ? '+' : ''}{delta}%
</span>
)
}
const showEye = !isPast && !!bookingUrl && !!rate
const showHistory = !!rate
const isRowH = hoveredCell?.row === rowIdx
const isColH = hoveredCell?.col === colIdx
const isCellH = isRowH && isColH
return (
<td
key={d}
style={mergeStyles(
styles.matrixTd,
cellStyle,
isStale ? { fontStyle: 'italic' } : {},
isWeekend(d) ? styles.weekendCell : {},
isRowH || isColH ? styles.crosshairHighlight : {},
isCellH ? styles.crosshairCell : {}
)}
title={tooltip}
onMouseEnter={() => onCellEnter(rowIdx, colIdx)}
onMouseLeave={onCellLeave}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
<span style={{ textDecoration: strikethrough ? 'line-through' : 'none' }}>
{rateText}
</span>
{priceIndexBadge}
{(showEye || showHistory) && (
<div style={{ display: 'flex', gap: 4, marginTop: 2, opacity: 0.6 }}>
{showEye && (
<a
href={bookingUrl!}
target="_blank"
rel="noopener noreferrer"
title="View on Booking.com"
style={{ color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
onClick={e => e.stopPropagation()}
>
<Eye size={12} strokeWidth={1.75} />
</a>
)}
{showHistory && (
<button
title="Rate history"
onClick={() => setHistoryModal({ hotelId: hotel.id, hotelName: hotel.name, stayDate: d })}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'inherit', display: 'inline-flex', alignItems: 'center', lineHeight: 1 }}
>
<LineChart size={12} strokeWidth={1.75} />
</button>
)}
</div>
)}
</div>
</td>
)
})}
</tr>
{/* Own hotel direct sub-row — Newbook best available */}
{showDirect && hotel.tier === 'own' && ownDirectRates &&
Object.keys(ownDirectRates).length > 0 && (
<tr style={{ background: '#fafbfc' }}>
<td style={{ ...styles.matrixTd, ...styles.stickyCol, paddingLeft: 28, fontSize: 11, color: 'var(--text-mid)', fontStyle: 'italic' }}>
Direct (Newbook)
</td>
{dates.map(d => {
const own = ownDirectRates[d]
return (
<td key={d} title={own?.tariff}
style={{ ...styles.matrixTd, fontSize: 11, color: own ? 'var(--text-dark)' : 'var(--text-mid)',
background: isWeekend(d) ? '#fdf8f0' : undefined }}>
{own ? `£${Number(own.rate).toFixed(0)}` : '—'}
</td>
)
})}
</tr>
)}
{/* Direct rates sub-row — only when this hotel actually has direct rates */}
{showDirect && hotel.tier === 'competitor' &&
Object.values(directRatesMap?.[hotel.id] || {}).some(v => v != null) && (
<tr style={{ background: '#fafbfc' }}>
<td style={{ ...styles.matrixTd, ...styles.stickyCol, paddingLeft: 28, fontSize: 11, color: 'var(--text-mid)', fontStyle: 'italic' }}>
Direct
</td>
{dates.map(d => {
const directRate = directRatesMap?.[hotel.id]?.[d] ?? null
return (
<td key={d} style={{ ...styles.matrixTd, fontSize: 11, color: directRate ? 'var(--text-dark)' : 'var(--text-mid)',
background: isWeekend(d) ? '#fdf8f0' : undefined }}>
{directRate ? `£${Number(directRate).toFixed(0)}` : '—'}
</td>
)
})}
</tr>
)}
</React.Fragment>
)
})}
</tbody>
</table>
</div>
)}
{historyModal && (
<RateHistoryModalComponent modal={historyModal} onClose={() => setHistoryModal(null)} />
)}
{/* Legend */}
<div style={styles.legend}>
<span style={styles.legendTitle}>Legend:</span>
<span style={mergeStyles(styles.legendItem, styles.matrixCellAvailable)}>Available</span>
<span style={mergeStyles(styles.legendItem, styles.matrixCellSoldOut)}>Sold Out (strikethrough = last rate)</span>
<span style={mergeStyles(styles.legendItem, styles.matrixCellNoRate)}>No Rate</span>
<span style={mergeStyles(styles.legendItem, styles.matrixCellPast)}>Past</span>
<span style={mergeStyles(styles.legendItem, styles.matrixCellEmpty)}>No Data</span>
<span style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '11px' }}>
<span style={mergeStyles(styles.tierDot, { background: '#2563eb' })} /> Own
<span style={mergeStyles(styles.tierDot, { background: '#d97706' })} /> Competitor
<span style={mergeStyles(styles.tierDot, { background: '#64748b' })} /> Market
</span>
</div>
</div>
)
}
// ============================================
// MAIN COMPONENT
// ============================================
const CompetitorRates: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('matrix')
const { data: status, isLoading: statusLoading } = useQuery<ScraperStatus>({
queryKey: ['scraper-status'],
queryFn: async () => (await api.get('/competitors/status')).data,
refetchInterval: 30000,
})
const tabs: { id: TabId; label: string }[] = [
{ id: 'matrix', label: 'Rate Matrix' },
{ id: 'hotels', label: 'Hotels' },
{ id: 'parity', label: 'Parity Alerts' },
{ id: 'settings', label: 'Scraper Settings' },
]
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.pageHeader}>
<div>
<h1 style={styles.title}>Competitor Rates</h1>
<p style={styles.subtitle}>
Compare rates across competitor hotels from Booking.com
</p>
</div>
</div>
{/* Status Bar */}
<StatusPanel status={status} isLoading={statusLoading} />
{/* Tabs */}
<div style={styles.tabBar}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={mergeStyles(
styles.tab,
activeTab === tab.id ? styles.tabActive : {}
)}
>
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<div style={styles.tabContent}>
{activeTab === 'matrix' && <RateMatrixTab />}
{activeTab === 'hotels' && <HotelsTab />}
{activeTab === 'parity' && <ParityAlertsTab />}
{activeTab === 'settings' && <SettingsTab />}
</div>
</div>
)
}
// ============================================
// STYLES
// ============================================
const styles: Record<string, React.CSSProperties> = {
container: {
padding: '24px',
maxWidth: '100%',
margin: '0 auto',
},
pageHeader: {
marginBottom: '16px',
},
title: {
fontSize: '24px',
fontWeight: 700,
color: 'var(--text-dark)',
margin: 0,
},
subtitle: {
fontSize: '13px',
color: 'var(--text-mid)',
margin: '4px 0 0',
},
// Status bar
statusBar: {
display: 'flex',
gap: '24px',
padding: '16px',
background: 'var(--card-bg)',
borderRadius: '10px',
boxShadow: 'var(--shadow-sm)',
marginBottom: '16px',
flexWrap: 'wrap',
alignItems: 'center',
},
statusItem: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
statusLabel: {
fontSize: '11px',
color: 'var(--text-mid)',
textTransform: 'uppercase',
fontWeight: 500,
},
// Tabs
tabBar: {
display: 'flex',
gap: '4px',
borderBottom: '2px solid var(--card-border)',
marginBottom: '24px',
},
tab: {
padding: '8px 24px',
background: 'transparent',
border: 'none',
borderBottom: '2px solid transparent',
cursor: 'pointer',
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-mid)',
marginBottom: '-2px',
transition: 'all 0.2s',
},
tabActive: {
color: 'var(--navy)',
borderBottomColor: 'var(--navy)',
fontWeight: 600,
},
tabContent: {
minHeight: '300px',
},
// Cards
card: {
background: 'var(--card-bg)',
borderRadius: '10px',
padding: '24px',
boxShadow: 'var(--shadow-md)',
},
cardTitle: {
fontSize: '16px',
fontWeight: 600,
color: 'var(--text-dark)',
margin: '0 0 4px',
},
cardDescription: {
fontSize: '13px',
color: 'var(--text-mid)',
margin: '0 0 16px',
},
// Settings
settingsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))',
gap: '24px',
},
formRow: {
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
},
formGroup: {
flex: 1,
minWidth: '200px',
},
formGroupSmall: {
width: '80px',
},
controlRow: {
display: 'flex',
gap: '16px',
flexWrap: 'wrap',
},
errorText: {
color: 'var(--danger)',
fontSize: '13px',
marginTop: '8px',
},
hintText: {
color: 'var(--text-mid)',
fontSize: '11px',
marginTop: '8px',
fontStyle: 'italic',
},
// Hotels
filterRow: {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '24px',
flexWrap: 'wrap',
},
filterLabel: {
fontSize: '13px',
fontWeight: 500,
color: 'var(--text-mid)',
},
tierSection: {
marginBottom: '24px',
},
tierHeader: {
fontSize: '14px',
fontWeight: 600,
margin: '0 0 8px',
},
hotelCard: {
background: 'var(--card-bg)',
borderRadius: '10px',
padding: '16px',
boxShadow: 'var(--shadow-sm)',
marginBottom: '8px',
},
hotelHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '16px',
},
hotelInfo: {
flex: 1,
},
hotelName: {
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-dark)',
},
hotelMeta: {
display: 'flex',
gap: '16px',
fontSize: '11px',
color: 'var(--text-mid)',
marginTop: '4px',
},
hotelActions: {
display: 'flex',
gap: '8px',
},
tierSelect: {
padding: '4px 8px',
borderRadius: '6px',
border: '1px solid var(--card-border)',
fontSize: '11px',
cursor: 'pointer',
background: 'var(--card-bg)',
},
hotelFooter: {
display: 'flex',
justifyContent: 'space-between',
marginTop: '8px',
paddingTop: '8px',
borderTop: '1px solid var(--card-border)',
},
emptyState: {
textAlign: 'center',
padding: '48px',
background: 'var(--card-bg)',
borderRadius: '10px',
boxShadow: 'var(--shadow-sm)',
},
// Rate Matrix
matrixControls: {
display: 'flex',
alignItems: 'center',
gap: '24px',
marginBottom: '24px',
flexWrap: 'wrap',
},
monthSelector: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
monthDropdown: {
fontSize: '14px',
fontWeight: 500,
color: 'var(--text-dark)',
padding: '4px 8px',
borderRadius: '6px',
border: '1px solid var(--card-border)',
background: 'var(--card-bg)',
cursor: 'pointer',
minWidth: '160px',
},
checkboxLabel: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '13px',
color: 'var(--text-mid)',
cursor: 'pointer',
},
matrixContainer: {
overflowX: 'auto',
background: 'var(--card-bg)',
borderRadius: '10px',
boxShadow: 'var(--shadow-md)',
},
matrixTable: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '11px',
minWidth: '800px',
},
matrixTh: {
padding: '8px',
borderBottom: '2px solid var(--card-border)',
textAlign: 'center',
fontWeight: 600,
color: 'var(--text-dark)',
whiteSpace: 'nowrap',
background: 'var(--card-bg)',
fontSize: '11px',
},
matrixTd: {
padding: '4px 8px',
borderBottom: '1px solid var(--card-border)',
textAlign: 'center',
whiteSpace: 'nowrap',
fontSize: '11px',
},
stickyCol: {
position: 'sticky',
left: 0,
background: 'var(--card-bg)',
zIndex: 10,
textAlign: 'left',
minWidth: '180px',
maxWidth: '220px',
borderRight: '1px solid var(--card-border)',
},
hotelNameCell: {
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
},
matrixHotelInfo: {
display: 'flex',
alignItems: 'center',
gap: '8px',
},
tierDot: {
width: '8px',
height: '8px',
borderRadius: '50%',
flexShrink: 0,
display: 'inline-block',
},
matrixHotelName: {
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
matrixStars: {
color: '#d97706',
fontSize: '11px',
flexShrink: 0,
},
dateHeader: {
minWidth: '50px',
padding: '4px',
},
dateHeaderContent: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2px',
},
dayOfWeek: {
fontSize: '11px',
color: 'var(--text-mid)',
},
dayNum: {
fontSize: '13px',
fontWeight: 600,
},
scrapeAge: {
fontSize: '9px',
color: 'var(--success)',
fontWeight: 400,
opacity: 0.8,
lineHeight: 1,
},
weekendHeader: {
background: 'var(--body-bg)',
},
weekendCell: {
borderLeft: '2px solid var(--card-border)',
},
matrixCellAvailable: {
background: '#dcfce7',
color: 'var(--success)',
fontWeight: 600,
},
matrixCellSoldOut: {
background: '#fef3c7',
color: '#d97706',
},
matrixCellNoRate: {
background: '#fef9ec',
color: '#b45309',
},
matrixCellEmpty: {
background: 'var(--body-bg)',
color: 'var(--text-mid)',
},
matrixCellPast: {
background: '#f1f5f9',
color: '#94a3b8',
},
crosshairHighlight: {
boxShadow: 'inset 0 0 0 1px #1a1a2e33',
background: '#1a1a2e08',
},
crosshairCell: {
boxShadow: 'inset 0 0 0 2px var(--navy)',
},
crosshairRow: {
boxShadow: 'inset 0 0 0 1px #1a1a2e33',
background: '#1a1a2e08',
},
crosshairCol: {
boxShadow: 'inset 0 0 0 1px #1a1a2e33',
background: '#1a1a2e08',
},
scrapeBtn: {
background: 'none',
border: '1px solid var(--card-border)',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '10px',
lineHeight: 1,
padding: '2px 4px',
color: 'var(--text-mid)',
opacity: 0.6,
transition: 'opacity 0.15s',
},
scrapeBtnActive: {
opacity: 1,
color: 'var(--navy)',
borderColor: 'var(--navy)',
},
// Shared
table: {
width: '100%',
borderCollapse: 'collapse',
fontSize: '13px',
},
th: {
padding: '8px',
borderBottom: '2px solid var(--card-border)',
textAlign: 'left',
fontWeight: 600,
color: 'var(--text-dark)',
whiteSpace: 'nowrap',
fontSize: '11px',
},
td: {
padding: '8px',
borderBottom: '1px solid var(--card-border)',
fontSize: '13px',
},
historyTable: {
overflowX: 'auto',
},
noData: {
textAlign: 'center',
padding: '24px',
color: 'var(--text-mid)',
fontSize: '13px',
},
legend: {
display: 'flex',
alignItems: 'center',
gap: '16px',
marginTop: '24px',
padding: '16px',
background: 'var(--card-bg)',
borderRadius: '10px',
fontSize: '13px',
flexWrap: 'wrap',
},
legendTitle: {
fontWeight: 600,
color: 'var(--text-dark)',
},
legendItem: {
padding: '4px 8px',
borderRadius: '4px',
fontSize: '11px',
},
loading: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '48px',
gap: '16px',
color: 'var(--text-mid)',
},
spinner: {
width: '40px',
height: '40px',
border: '3px solid var(--card-border)',
borderTop: '3px solid var(--navy)',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
errorBox: {
padding: '24px',
background: '#fee2e2',
color: 'var(--danger)',
borderRadius: '10px',
},
// Schedule
scheduleGrid: {
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
scheduleTier: {
padding: '8px',
background: 'var(--body-bg)',
borderRadius: '6px',
},
scheduleTierHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
scheduleTierName: {
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-dark)',
textTransform: 'capitalize',
},
scheduleTierDesc: {
fontSize: '11px',
color: 'var(--text-mid)',
margin: '4px 0 0',
},
scheduleTierRange: {
fontSize: '11px',
color: 'var(--text-mid)',
margin: '2px 0 0',
fontFamily: 'monospace',
},
queuePanel: {
padding: '8px',
background: 'var(--body-bg)',
borderRadius: '6px',
},
queueStats: {
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
},
// Coverage grid
coverageContainer: {
display: 'flex',
flexDirection: 'column',
gap: '16px',
},
coverageLegend: {
display: 'flex',
alignItems: 'center',
gap: '8px',
fontSize: '11px',
flexWrap: 'wrap',
},
coverageLegendItem: {
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
},
coverageMonth: {
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
},
coverageMonthLabel: {
fontSize: '11px',
fontWeight: 600,
color: 'var(--text-dark)',
minWidth: '70px',
paddingTop: '3px',
flexShrink: 0,
},
coverageCells: {
display: 'flex',
flexWrap: 'wrap',
gap: '3px',
},
coverageCell: {
width: '32px',
height: '28px',
borderRadius: '3px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'default',
lineHeight: 1,
border: '1px solid rgba(0,0,0,0.06)',
},
coverageCellDay: {
fontSize: '9px',
fontWeight: 600,
},
coverageCellTier: {
fontSize: '7px',
opacity: 0.7,
},
}
export default CompetitorRates