- 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>
2493 lines
89 KiB
TypeScript
2493 lines
89 KiB
TypeScript
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')}>
|
||
←
|
||
</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')}>
|
||
→
|
||
</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
|