diff --git a/backend/services/scraper_backends/playwright_hotel_page.py b/backend/services/scraper_backends/playwright_hotel_page.py
index 93d7328..64cd04b 100644
--- a/backend/services/scraper_backends/playwright_hotel_page.py
+++ b/backend/services/scraper_backends/playwright_hotel_page.py
@@ -107,12 +107,25 @@ _EXTRACT_RATES_JS = """
let fltrs = {};
try { fltrs = JSON.parse(tr.getAttribute('data-fltrs') || '{}'); } catch(e) {}
- // Conditions cell (3rd
) holds meal plan + cancel info
const cells = tr.querySelectorAll('td');
- const condCell = cells.length >= 3 ? cells[2].innerText || '' : '';
- const breakfastIncluded = condCell.toLowerCase().includes('breakfast');
- const nonRefundable = (fltrs.non_refundable === 1);
+ // Scan ALL cells for breakfast — the conditions column index varies
+ // across hotel page templates (some hotels swap occupancy + conditions).
+ // Also check data-fltrs which carries a mealplan flag on some properties.
+ const rowText = (tr.innerText || '').toLowerCase();
+ const breakfastIncluded = rowText.includes('breakfast')
+ || fltrs.mealplan === 1
+ || fltrs.breakfast_included === 1;
+
+ const nonRefundable = (fltrs.non_refundable === 1);
+
+ // Cancellation text: find whichever cell mentions it.
+ let condCell = '';
+ for (const cell of cells) {
+ const t = cell.innerText || '';
+ if (t.toLowerCase().includes('free cancellation')) { condCell = t; break; }
+ }
+ if (!condCell && cells.length >= 3) condCell = cells[2].innerText || '';
const cancelMatch = condCell.match(/free cancellation before ([\\w\\s]+)/i);
const freeCancelText = cancelMatch ? cancelMatch[1].trim() : null;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index e683936..182dc72 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,7 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import AuthGate from './components/AuthGate'
+import { UpdateBanner } from './components/UpdateBanner'
+import { useVersionCheck } from './hooks/useVersionCheck'
import Layout from './components/Layout'
import Bookability from './pages/Bookability'
import MarketView from './pages/MarketView'
@@ -8,7 +10,9 @@ import RateAnalysis from './pages/RateAnalysis'
import Settings from './pages/Settings'
export default function App() {
+ const updateAvailable = useVersionCheck('/rates/health')
return (
+ <>
@@ -24,5 +28,7 @@ export default function App() {
+
+ >
)
}
diff --git a/frontend/src/components/UpdateBanner.tsx b/frontend/src/components/UpdateBanner.tsx
new file mode 100644
index 0000000..b492c67
--- /dev/null
+++ b/frontend/src/components/UpdateBanner.tsx
@@ -0,0 +1,44 @@
+import { RefreshCw } from 'lucide-react'
+
+export function UpdateBanner({ visible }: { visible: boolean }) {
+ if (!visible) return null
+ return (
+
+ A new version is available.
+
+
+ )
+}
diff --git a/frontend/src/hooks/useVersionCheck.ts b/frontend/src/hooks/useVersionCheck.ts
new file mode 100644
index 0000000..e4fed28
--- /dev/null
+++ b/frontend/src/hooks/useVersionCheck.ts
@@ -0,0 +1,43 @@
+import { useEffect, useState } from 'react'
+
+const POLL_MS = 2 * 60 * 1000
+
+export function useVersionCheck(healthUrl: string) {
+ const [updateAvailable, setUpdateAvailable] = useState(false)
+
+ useEffect(() => {
+ let seenVersion: string | null = null
+
+ async function check() {
+ try {
+ const res = await fetch(healthUrl, { cache: 'no-store' })
+ if (!res.ok) return
+ const data = await res.json()
+ const v: string | undefined = data.version
+ if (!v) return
+ if (seenVersion === null) {
+ seenVersion = v
+ } else if (v !== seenVersion) {
+ setUpdateAvailable(true)
+ }
+ } catch {
+ // network error — skip silently
+ }
+ }
+
+ check()
+ const interval = setInterval(check, POLL_MS)
+
+ function onVisible() {
+ if (document.visibilityState === 'visible') check()
+ }
+ document.addEventListener('visibilitychange', onVisible)
+
+ return () => {
+ clearInterval(interval)
+ document.removeEventListener('visibilitychange', onVisible)
+ }
+ }, [healthUrl])
+
+ return updateAvailable
+}
|