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 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> } 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 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 = { 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
Loading status...
if (!status) return null return (
Scraper {status.enabled ? 'Enabled' : 'Disabled'}
Location {status.location_name || 'Not configured'}
Backend {status.backend}
{status.last_scrape && (
Last Scrape {status.last_scrape.status} {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` : ''}
)}
) } // ============================================ // 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({ queryKey: ['scraper-status'], queryFn: async () => (await api.get('/competitors/status')).data, }) const { data: history } = useQuery({ queryKey: ['scrape-history'], queryFn: async () => (await api.get('/competitors/scrape-history?limit=10')).data, }) const { data: scheduleInfo } = useQuery({ queryKey: ['schedule-info'], queryFn: async () => (await api.get('/competitors/schedule-info')).data, }) const { data: queueStatus } = useQuery({ queryKey: ['queue-status'], queryFn: async () => (await api.get('/competitors/queue-status')).data, refetchInterval: 30000, }) const { data: coverage } = useQuery({ 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 (
{/* Location Configuration */}

Location Configuration

Set the location for competitor rate scraping. {status?.location_name && ( <> Currently: {status.location_name} )}

setLocationName(e.target.value)} placeholder="e.g. Bowness-on-Windermere" style={inputStyle} />
setSearchUrl(e.target.value)} placeholder="https://www.booking.com/searchresults.html?ss=…&dest_id=…" style={inputStyle} />

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.

setPages(parseInt(e.target.value) || 2)} min={1} max={5} style={inputStyle} />
setAdults(parseInt(e.target.value) || 2)} min={1} max={4} style={inputStyle} />
{setLocationMutation.isError && (

{(setLocationMutation.error as any)?.response?.data?.detail || 'Failed to set location'}

)}
{/* Scraper Controls */}

Scraper Controls

{/* Schedule Info */}

Automatic Schedule

{scheduleInfo ? (

Runs daily at {scheduleInfo.daily_time} ({scheduleInfo.weekday})

{Object.entries(scheduleInfo.tiers).map(([key, tier]) => (
{key} 0 ? 'info' : 'warning')}> {tier.dates_today} dates

{tier.description}

{tier.range && (

{tier.range}

)}
))}

Total today: {scheduleInfo.total_dates_today} dates

) : (

Loading schedule...

)} {/* Queue Status */} {queueStatus && (queueStatus.total_pending > 0 || queueStatus.total_failed > 0) && (

Queue

{queueStatus.total_pending > 0 && ( {queueStatus.total_pending} pending )} {queueStatus.retries_pending > 0 && ( {queueStatus.retries_pending} retries )} {queueStatus.total_completed > 0 && ( {queueStatus.total_completed} done )} {queueStatus.total_failed > 0 && ( {queueStatus.total_failed} failed )}
)}
{/* Manual Scrape */}

Manual Scrape

Trigger a one-off scrape for a date range. Runs in background.

setScrapeFrom(e.target.value)} style={inputStyle} />
setScrapeTo(e.target.value)} style={inputStyle} />
{!status?.location_configured && (

Configure a location first

)} {scrapeMutation.isSuccess && (

Scrape started! Check status for progress.

)} {scrapeMutation.isError && (

{(scrapeMutation.error as any)?.response?.data?.detail || 'Failed to start scrape'}

)}
{/* Scrape History */}

Scrape History

{history && history.length > 0 ? (
{history.map(entry => ( ))}
Type Started Status Hotels Rates Error
{entry.scrape_type} {formatDateTime(entry.started_at)} {entry.status} {entry.hotels_found ?? '-'} {entry.rates_scraped ?? '-'} {entry.error_message || '-'}
) : (

No scrape history yet

)}
{/* Scrape Coverage - 365 day view */}

Scrape Coverage (365 days)

Each cell is a date. Color shows freshness of data; letter shows priority (H=high, M=medium, L=low).

{coverage ? :

Loading coverage...

}
) } // ============================================ // 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 = {} 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 (
{/* Legend */}
{'<'}24h 1-3d 3-7d 7-14d {'>'} 14d Never H=High M=Medium L=Low priority
{months.map(([monthKey, entries]) => (
{formatMonthLabel(monthKey)}
{entries.map(entry => (
{new Date(entry.date + 'T00:00:00').getDate()} {tierLabel(entry.tier)}
))}
))}
) } // ============================================ // 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('active') const { data: alerts, isLoading } = useQuery({ queryKey: ['parity-alerts', statusFilter], queryFn: async () => { const params = statusFilter ? `?status=${statusFilter}` : '' return (await api.get(`/competitors/parity/alerts${params}`)).data }, }) const { data: parityConfig } = useQuery>({ 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 (
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.
Status: {['active', 'acknowledged', 'resolved', ''].map(s => ( ))}
{isLoading && (
Loading alerts...
)} {!isLoading && (!alerts || alerts.length === 0) && (

No {statusFilter || ''} parity alerts

{statusFilter === 'active' ? 'Booking.com is pricing within the expected band of Newbook.' : 'Nothing here yet.'}

)} {!isLoading && alerts && alerts.length > 0 && (
{alerts.map(a => ( ))}
Date Newbook Booking.com Deviation Room Status
{a.rate_date} {a.newbook_rate != null ? `£${a.newbook_rate.toFixed(2)}` : '—'} {a.booking_com_rate != null ? `£${a.booking_com_rate.toFixed(2)}` : '—'} {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 ( {a.difference_pct > 0 ? '+' : ''}{a.difference_pct.toFixed(1)}% {devGbp != null ? ` (${devGbp > 0 ? '+' : '−'}£${Math.abs(devGbp).toFixed(2)})` : ''} vs expected ) })()} {a.room_category || '—'} {a.alert_status} {a.alert_status === 'active' && ( )} {a.alert_status === 'acknowledged' && a.acknowledged_by && ( by {a.acknowledged_by} )}
)}
) } const HotelsTab: React.FC = () => { const queryClient = useQueryClient() const [tierFilter, setTierFilter] = useState('') const { data: hotels, isLoading } = useQuery({ 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 }) => (
{hotel.name}
{hotel.star_rating && {hotel.star_rating} stars} {hotel.review_score && Score: {hotel.review_score}} {hotel.review_count && ({hotel.review_count} reviews)}
{hotel.tier === 'competitor' && directHotels && directHotels.length > 0 && ( )}
ID: {hotel.booking_com_id} {hotel.last_seen_at && ( Last seen: {formatDateTime(hotel.last_seen_at)} )}
) if (isLoading) { return (
Loading hotels...
) } return (
{/* Filter */}
Filter: {['', 'own', 'competitor', 'market'].map(t => ( ))}
{/* Hotels */} {!hotels || hotels.length === 0 ? (

No Hotels Discovered

Run a scrape to discover hotels in your configured location.

) : (
{/* Own Hotel */} {grouped.own.length > 0 && (

Your Hotel ({grouped.own.length})

{grouped.own.map(h => )}
)} {/* Competitors */} {grouped.competitor.length > 0 && (

Competitors ({grouped.competitor.length})

{grouped.competitor.map(h => )}
)} {/* Market */} {grouped.market.length > 0 && (

Market ({grouped.market.length})

{grouped.market.map(h => )}
)}
)}
) } // ============================================ // 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 (
) } // ============================================ // 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({ 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}
%{x}${s.room_type}`, })) }, [histData]) const isLoading = histLoading || snapLoading return (
e.stopPropagation()}>
{modal.hotelName}
{modal.stayDate}
{isLoading ? (
Loading…
) : ( <> {/* Current availability snapshot */} {snap && snap.rooms.length > 0 && (
Current availability
{snap.rooms.map(room => (
{room.room_type} {room.availability_status === 'sold_out' ? ( Sold out ) : room.rooms_left != null ? ( {room.rooms_left} left ) : null}
{room.plans.length > 0 && ( {room.plans.map((plan, pi) => ( ))}
{plan.meal} {plan.cancel} £{plan.price.toLocaleString()}
)} {room.plans.length === 0 && room.availability_status !== 'sold_out' && (
No rates available
)}
))}
)} {snap && snap.rooms.length === 0 && (
No rate data from latest scrape for this date.
)} {/* Rate history chart */} {histData?.series && histData.series.length > 0 && ( <>
Rate history — best available per room type
)} {histData?.series && histData.series.length === 0 && (
No historical rate data yet — history builds up over time as daily scrapes run.
)} )}
) } 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(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([]) 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({ 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({ 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 = { 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 = {} 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 const result: Record = {} 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>>({ 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> = {} 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>({ 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 (
Loading rate matrix...
) } if (error) { return (
{(error as any)?.response?.data?.detail || 'Failed to load rate matrix'}
) } return (
{/* Controls */}
{!rangeMode ? ( ) : (
setCustomFrom(e.target.value)} /> to setCustomTo(e.target.value)} />
)}
{[{ label: '7d', days: 7 }, { label: '14d', days: 14 }, { label: '30d', days: 30 }, { label: '90d', days: 90 }].map(p => ( ))} {rangeMode && ( )}
{[{ 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 ( ) })}
{hotels.length === 0 ? (

No Rate Data

Run a scrape and categorize hotels as competitors to see rate comparisons.

) : (
{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 ( ) })} {hotels.map((hotel, rowIdx) => { const hotelRates = rates[hotel.id] || {} const isRowHovered = hoveredCell?.row === rowIdx return ( {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 = ( {delta > 0 ? '+' : ''}{delta}% ) } const showEye = !isPast && !!bookingUrl && !!rate const showHistory = !!rate const isRowH = hoveredCell?.row === rowIdx const isColH = hoveredCell?.col === colIdx const isCellH = isRowH && isColH return ( ) })} {/* Own hotel direct sub-row — Newbook best available */} {showDirect && hotel.tier === 'own' && ownDirectRates && Object.keys(ownDirectRates).length > 0 && ( {dates.map(d => { const own = ownDirectRates[d] return ( ) })} )} {/* 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) && ( {dates.map(d => { const directRate = directRatesMap?.[hotel.id]?.[d] ?? null return ( ) })} )} ) })}
Hotel
{formatDayOfWeek(d)} {formatDateShort(d)} {scrapeAge ? ( {scrapeAge} ) : null}
{locationName && d >= todayStr && ( )}
{hotel.name} {hotel.star_rating && ( {hotel.star_rating}* )}
onCellEnter(rowIdx, colIdx)} onMouseLeave={onCellLeave} >
{rateText} {priceIndexBadge} {(showEye || showHistory) && (
{showEye && ( e.stopPropagation()} > )} {showHistory && ( )}
)}
Direct (Newbook) {own ? `£${Number(own.rate).toFixed(0)}` : '—'}
Direct {directRate ? `£${Number(directRate).toFixed(0)}` : '—'}
)} {historyModal && ( setHistoryModal(null)} /> )} {/* Legend */}
Legend: Available Sold Out (strikethrough = last rate) No Rate Past No Data Own Competitor Market
) } // ============================================ // MAIN COMPONENT // ============================================ const CompetitorRates: React.FC = () => { const [activeTab, setActiveTab] = useState('matrix') const { data: status, isLoading: statusLoading } = useQuery({ 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 (
{/* Header */}

Competitor Rates

Compare rates across competitor hotels from Booking.com

{/* Status Bar */} {/* Tabs */}
{tabs.map(tab => ( ))}
{/* Tab Content */}
{activeTab === 'matrix' && } {activeTab === 'hotels' && } {activeTab === 'parity' && } {activeTab === 'settings' && }
) } // ============================================ // STYLES // ============================================ const styles: Record = { 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