Booking.com Mobile Price Viewer

Displays mobile-exclusive prices on the desktop version of Booking.com

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Booking.com Mobile Price Viewer
// @namespace    https://github.com/MichaelYochpaz/User-Scripts
// @version      1.1.0
// @description  Displays mobile-exclusive prices on the desktop version of Booking.com
// @author       Michael Yochpaz
// @match        *://*.booking.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      booking.com
// @noframes
// @run-at       document-idle
// @icon         https://cf.bstatic.com/static/img/favicon/40749a316c45e239a7149b6711ea4c48d10f8d89.ico
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // ============================================================================
    // CONFIGURATION
    // ============================================================================
    const CONFIG = {
        mobileUserAgent: 'Mozilla/5.0 (Linux; Android 16) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.7390.123 Mobile Safari/537.36',
        
        // Timing configuration (milliseconds)
        debounceDelay: 500,
        reinjectDelay: 100,
        networkTimeout: 10000,
        initialWaitTime: 1000,
        spaCheckInterval: 2000,
        
        // Display configuration
        badgeIcon: '📱',
        badgeTextFormat: '{price}',
        tooltipText: 'Mobile-exclusive price — book via a mobile device to get this rate (added by Mobile Price Viewer userscript)',
        badgeClassName: 'mobile-price-badge',
        
        debug: false,
        
        // CSS Selectors - These are based on Booking.com's current structure
        // NOTE: These selectors may need updates if Booking.com changes their layout
        selectors: {
            // Search results page selectors
            hotelCard: '[data-testid="property-card"]',
            hotelTitle: '[data-testid="title"]',
            
            // Property page selectors
            propertyRoomRow: 'tbody tr[data-block-id]',
            propertyPriceCell: '.hprt-table-cell-price',
            propertyPriceBlock: '.hprt-price-block',
            mobileRoomOption: 'label[role="radio"][data-block-id]',
            mobileDealBadge_Property: '.bui-badge.bui-badge--constructive',
            mobilePrice_Property: '.bui-price-display__value .prco-valign-middle-helper',
        },
        
        // CSS styles for the mobile price badge and card highlight
        style: `
            /* Card-level highlight for properties with mobile deals */
            [data-testid="property-card"].mobile-deal-card {
                background: linear-gradient(to left, rgba(185, 220, 190, 0.45), transparent 50%), #fff !important;
                border-inline-end: 3px solid #66bb6a !important;
            }
            
            [dir="rtl"] [data-testid="property-card"].mobile-deal-card {
                background: linear-gradient(to right, rgba(185, 220, 190, 0.45), transparent 50%), #fff !important;
            }
            
            .mobile-price-badge {
                display: inline-flex;
                align-items: center;
                gap: 4px;
                padding: 3px 10px;
                margin-top: 4px;
                background: #dcefdc;
                border: 1px solid #81c784;
                border-radius: 4px;
                font-size: 14px;
                font-weight: 700;
                color: #2e7d32;
                cursor: help;
                position: relative;
                direction: ltr;
                unicode-bidi: isolate;
                transition: background 0.15s ease;
            }
            
            .mobile-price-badge:hover {
                background: #c8e6c9;
            }
            
            .mobile-price-badge-icon {
                font-size: 14px;
                flex-shrink: 0;
                line-height: 1;
            }
            
            .mobile-price-badge-text {
                line-height: 1;
            }
            
            /* Tooltip on hover */
            .mobile-price-badge::after {
                content: attr(data-tooltip);
                position: absolute;
                bottom: 100%;
                left: 0;
                transform: translateY(-6px);
                padding: 6px 10px;
                background: #333;
                color: #fff;
                font-size: 11px;
                font-weight: 400;
                border-radius: 4px;
                width: max-content;
                max-width: 260px;
                white-space: normal;
                line-height: 1.4;
                opacity: 0;
                pointer-events: none;
                transition: opacity 0.15s ease;
                z-index: 1000;
            }
            
            .mobile-price-badge::before {
                content: '';
                position: absolute;
                bottom: 100%;
                left: 12px;
                border: 5px solid transparent;
                border-top-color: #333;
                opacity: 0;
                pointer-events: none;
                transition: opacity 0.15s ease;
                z-index: 1000;
            }
            
            .mobile-price-badge:hover::after {
                opacity: 1;
            }
            
            .mobile-price-badge:hover::before {
                opacity: 1;
            }
        `
    };

    // ============================================================================
    // UTILITY FUNCTIONS
    // ============================================================================
    
    /**
     * Debug logger
     * @param {...any} args - Arguments to log
     */
    function log(...args) {
        if (CONFIG.debug) {
            console.log('[Booking Mobile Prices]', ...args);
        }
    }
    
    /**
     * Error logger
     * @param {...any} args - Arguments to log
     */
    function logError(...args) {
        console.error('[Booking Mobile Prices ERROR]', ...args);
    }
    
    /**
     * Creates a debounced version of a function
     * @param {Function} func - Function to debounce
     * @param {number} wait - Wait time in milliseconds
     * @returns {Function} Debounced function
     */
    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func(...args), wait);
        };
    }
    
    /**
     * Detects the current page type (search results or property page)
     * @returns {string} Page type: 'search', 'property', or 'unknown'
     */
    function detectPageType() {
        const url = window.location.href;
        
        if (url.includes('searchresults')) {
            return 'search';
        } else if (url.includes('/hotel/')) {
            return 'property';
        }
        
        return 'unknown';
    }
    
    /**
     * Formats a price from a raw numeric value and currency code.
     * Uses a fixed locale to ensure consistent symbol-first formatting
     * regardless of the page's language (e.g., "₪ 2,407" instead of "2,407 ₪").
     * @param {number} amount - The unformatted numeric price
     * @param {string} currency - ISO 4217 currency code (e.g., "ILS", "EUR")
     * @returns {string} Formatted price string, or empty string on failure
     */
    function formatPrice(amount, currency) {
        if (amount == null || !currency) return '';
        try {
            return new Intl.NumberFormat('en', {
                style: 'currency',
                currency: currency,
                maximumFractionDigits: 0,
                minimumFractionDigits: 0,
            }).format(Math.round(amount));
        } catch {
            return '';
        }
    }
    
    /**
     * Extracts and normalizes price text from an element
     * @param {Element} parent - Parent element to search within
     * @param {string} selector - Selector for price element
     * @returns {string} Normalized price text
     */
    function extractPriceText(parent, selector) {
        const priceElement = parent.querySelector(selector);
        if (!priceElement) return '';
        
        return priceElement.textContent
            .trim()
            .replace(/\s+/g, ' ');
    }
    
    /**
     * Analyzes MutationObserver records to determine if re-injection or reprocessing is needed
     * @param {MutationRecord[]} mutations - The mutation records to analyze
     * @param {string} cardSelector - CSS selector for the card/row elements to watch for
     * @returns {{shouldReinject: boolean, shouldReprocess: boolean}} Analysis result
     */
    function analyzeMutations(mutations, cardSelector) {
        let shouldReinject = false;
        let shouldReprocess = false;
        
        for (const mutation of mutations) {
            if (mutation.type !== 'childList') continue;
            
            // Check if new card/row elements were added
            for (const node of mutation.addedNodes) {
                if (node.nodeType !== Node.ELEMENT_NODE) continue;
                // Skip mutations caused by our own badge injection
                if (node.classList?.contains(CONFIG.badgeClassName)) continue;
                
                if (node.matches?.(cardSelector) || node.querySelector?.(cardSelector)) {
                    shouldReprocess = true;
                    break;
                }
            }
            
            // Check if our badges were removed (e.g., by Booking.com's JS re-rendering)
            if (!shouldReinject) {
                for (const node of mutation.removedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;
                    
                    if (node.classList?.contains(CONFIG.badgeClassName) ||
                        node.querySelector?.(`.${CONFIG.badgeClassName}`)) {
                        shouldReinject = true;
                        break;
                    }
                }
            }
            
            if (shouldReprocess) break;
        }
        
        return { shouldReinject, shouldReprocess };
    }
    
    /**
     * Creates a badge element with icon and text
     * @param {string} priceText - The price text to display
     * @param {string} identifier - Identifier for tracking (hotel name or block ID)
     * @param {string} attributeName - Name of the data attribute ('data-hotel-name' or 'data-block-id')
     * @returns {HTMLElement} The created badge element
     */
    function createBadgeElement(priceText, identifier, attributeName) {
        const badge = document.createElement('div');
        badge.className = CONFIG.badgeClassName;
        badge.setAttribute('data-tooltip', CONFIG.tooltipText);
        badge.setAttribute(attributeName, identifier);
        
        const icon = document.createElement('span');
        icon.className = 'mobile-price-badge-icon';
        icon.textContent = CONFIG.badgeIcon;
        
        const text = document.createElement('span');
        text.className = 'mobile-price-badge-text';
        text.textContent = CONFIG.badgeTextFormat.replace('{price}', priceText);
        
        badge.appendChild(icon);
        badge.appendChild(text);
        
        return badge;
    }

    // ============================================================================
    // CORE FUNCTIONALITY
    // ============================================================================
    
    /**
     * Fetches the mobile version of the current page.
     * Uses callback-based GM_xmlhttpRequest wrapped in a Promise for
     * compatibility across Violentmonkey, Tampermonkey, and Greasemonkey.
     * @param {string} url - The URL to fetch
     * @returns {Promise<string>} The HTML content of the mobile page
     */
    function fetchMobilePage(url) {
        log('Fetching mobile page:', url);
        
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                headers: {
                    'User-Agent': CONFIG.mobileUserAgent,
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
                },
                timeout: CONFIG.networkTimeout,
                onload: (response) => {
                    if (response.status !== 200) {
                        logError('Failed to fetch mobile page:', `HTTP ${response.status}`);
                        reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
                    } else {
                        log('Mobile page fetched successfully');
                        resolve(response.responseText);
                    }
                },
                onerror: (error) => {
                    logError('Failed to fetch mobile page:', error);
                    reject(new Error('Network error'));
                },
                ontimeout: () => {
                    logError('Mobile page request timed out');
                    reject(new Error('Request timed out'));
                },
            });
        });
    }
    
    /**
     * Recursively traverses JSON data to find hotels with mobile-exclusive deals.
     * Looks for the priceDisplayInfoIrene object which contains language-invariant
     * badge identifiers (e.g., "Mobile Rate") and discount product IDs (e.g., "mobile-discount").
     * @param {any} obj - JSON object to traverse
     * @param {Map<string, object>} priceMap - Map to populate with results
     */
    function extractMobilePricesFromJSON(obj, priceMap) {
        if (!obj || typeof obj !== 'object') return;
        
        if (obj.priceDisplayInfoIrene && obj.displayName?.text) {
            const info = obj.priceDisplayInfoIrene;
            const badges = info.badges || [];
            const discounts = info.discounts || [];
            
            const isMobileDeal = badges.some(b => b.identifier === 'Mobile Rate') ||
                                 discounts.some(d => d.productId === 'mobile-discount');
            
            if (isMobileDeal) {
                const hotelName = obj.displayName.text.trim();
                const priceData = info.displayPrice?.amountPerStay;
                const priceText = formatPrice(priceData?.amountUnformatted, priceData?.currency)
                                  || priceData?.amount?.trim();
                
                if (hotelName && priceText) {
                    priceMap.set(hotelName, {
                        price: priceText,
                        rawPrice: priceData?.amountUnformatted,
                        hasMobileDeal: true,
                    });
                    log(`✓ Found mobile-only deal for "${hotelName}": ${priceText}`);
                }
            }
        }
        
        if (Array.isArray(obj)) {
            for (const item of obj) extractMobilePricesFromJSON(item, priceMap);
        } else {
            for (const key of Object.keys(obj)) {
                extractMobilePricesFromJSON(obj[key], priceMap);
            }
        }
    }
    
    /**
     * Parses mobile HTML and extracts hotel price data from embedded JSON.
     * Uses the priceDisplayInfoIrene GraphQL data structure which contains
     * language-invariant identifiers, making detection work across all locales.
     * @param {string} mobileHtml - The HTML content from the mobile page
     * @returns {Map<string, object>} Map of hotel names to price information
     */
    function parseMobilePrices(mobileHtml) {
        log('Parsing mobile HTML for prices...');
        
        try {
            const parser = new DOMParser();
            const mobileDoc = parser.parseFromString(mobileHtml, 'text/html');
            const priceMap = new Map();
            
            // Extract pricing data from embedded JSON <script> tags.
            // The priceDisplayInfoIrene object contains badge identifiers
            // (e.g., "Mobile Rate") that remain in English regardless of page language.
            for (const script of mobileDoc.querySelectorAll('script[type="application/json"]')) {
                const content = script.textContent;
                if (!content.includes('priceDisplayInfoIrene')) continue;
                
                try {
                    const data = JSON.parse(content);
                    extractMobilePricesFromJSON(data, priceMap);
                } catch (parseError) {
                    logError('Failed to parse embedded JSON:', parseError);
                }
            }
            
            log(`Price map created with ${priceMap.size} entries`);
            if (priceMap.size === 0) {
                log('⚠️ WARNING: No mobile prices found in embedded JSON. The data structure may have changed.');
            }
            return priceMap;
            
        } catch (error) {
            logError('Error parsing mobile HTML:', error);
            return new Map();
        }
    }
    
    /**
     * Parses mobile HTML for property pages and extracts room price data
     * @param {string} mobileHtml - The HTML content from the mobile page
     * @returns {Map<string, object>} Map of block IDs to price information
     */
    function parseMobileRoomPrices(mobileHtml) {
        log('Parsing mobile HTML for room prices...');
        
        try {
            const parser = new DOMParser();
            const mobileDoc = parser.parseFromString(mobileHtml, 'text/html');
            
            // Find all room options in mobile page
            const mobileRoomOptions = mobileDoc.querySelectorAll(CONFIG.selectors.mobileRoomOption);
            log(`Found ${mobileRoomOptions.length} room options in mobile HTML`);
            
            const priceMap = new Map();
            
            mobileRoomOptions.forEach((option, index) => {
                try {
                    const blockId = option.getAttribute('data-block-id');
                    if (!blockId) return;
                    
                    const dealBadge = option.querySelector(CONFIG.selectors.mobileDealBadge_Property);
                    if (!dealBadge) return;
                    
                    const priceText = extractPriceText(option, CONFIG.selectors.mobilePrice_Property);
                    if (priceText) {
                        priceMap.set(blockId, { price: priceText, hasMobileDeal: true });
                        log(`✓ Found mobile-only deal for block ${blockId}: ${priceText}`);
                    }
                } catch (error) {
                    logError(`Error parsing room option ${index}:`, error);
                }
            });
            
            log(`Price map created with ${priceMap.size} mobile-exclusive room deals`);
            return priceMap;
            
        } catch (error) {
            logError('Error parsing mobile room HTML:', error);
            return new Map();
        }
    }
    
    /**
     * Extracts desktop prices from the current page's embedded JSON data.
     * Uses the same priceDisplayInfoIrene structure as mobile, ensuring
     * consistent numeric comparison without currency parsing.
     * @returns {Map<string, number>} Map of hotel names to raw numeric prices
     */
    function getDesktopPrices() {
        const priceMap = new Map();
        
        for (const script of document.querySelectorAll('script[type="application/json"]')) {
            const content = script.textContent;
            if (!content.includes('priceDisplayInfoIrene')) continue;
            
            try {
                const data = JSON.parse(content);
                (function traverse(obj) {
                    if (!obj || typeof obj !== 'object') return;
                    
                    if (obj.priceDisplayInfoIrene && obj.displayName?.text) {
                        const name = obj.displayName.text.trim();
                        const amount = obj.priceDisplayInfoIrene.displayPrice?.amountPerStay?.amountUnformatted;
                        if (name && amount != null) {
                            priceMap.set(name, amount);
                        }
                    }
                    
                    if (Array.isArray(obj)) {
                        for (const item of obj) traverse(item);
                    } else {
                        for (const key of Object.keys(obj)) traverse(obj[key]);
                    }
                })(data);
            } catch (e) {
                logError('Failed to parse desktop JSON:', e);
            }
        }
        
        log(`Extracted ${priceMap.size} desktop prices from page JSON`);
        return priceMap;
    }
    
    /**
     * Injects mobile price badges into desktop hotel cards
     * @param {Map<string, object>} priceMap - Map of hotel names to price info
     */
    function injectMobilePrices(priceMap) {
        if (priceMap.size === 0) {
            log('No mobile prices to inject');
            return;
        }
        
        log('Injecting mobile prices into desktop cards...');
        
        // Get desktop prices for comparison (from page's embedded JSON)
        const desktopPrices = getDesktopPrices();
        
        // Find all desktop hotel cards
        const desktopCards = document.querySelectorAll(CONFIG.selectors.hotelCard);
        let injectedCount = 0;
        let skippedCount = 0;
        
        desktopCards.forEach((card, index) => {
            try {
                // Extract hotel name from desktop card
                const titleElement = card.querySelector(CONFIG.selectors.hotelTitle);
                if (!titleElement) {
                    return;
                }
                
                const hotelName = titleElement.textContent.trim();
                
                // Check if we have mobile price data for this hotel
                const mobileData = priceMap.get(hotelName);
                if (!mobileData) {
                    return;
                }
                
                // Compare mobile vs desktop price — only show badge if mobile is actually cheaper
                const desktopPrice = desktopPrices.get(hotelName);
                if (desktopPrice != null && mobileData.rawPrice != null) {
                    if (Math.round(mobileData.rawPrice) >= Math.round(desktopPrice)) {
                        skippedCount++;
                        log(`Skipping "${hotelName}": mobile price (${Math.round(mobileData.rawPrice)}) is not lower than desktop (${Math.round(desktopPrice)})`);
                        return;
                    }
                }
                
                if (card.querySelector(`.${CONFIG.badgeClassName}`)) return;
                
                const priceSection = findPriceSection(card);
                if (!priceSection) {
                    log(`Card ${index}: No price section found for "${hotelName}"`);
                    return;
                }
                
                const badge = createBadgeElement(mobileData.price, hotelName, 'data-hotel-name');
                priceSection.insertAdjacentElement('afterend', badge);
                
                // Add highlight to the card (green tint + border accent)
                card.classList.add('mobile-deal-card');
                
                injectedCount++;
                log(`Injected price for "${hotelName}": ${mobileData.price} (desktop: ${Math.round(desktopPrice || 0)})`);
                
            } catch (error) {
                logError(`Error injecting price for card ${index}:`, error);
            }
        });
        
        log(`Injected ${injectedCount} badges, skipped ${skippedCount} (same or higher price)`);
    }
    
    /**
     * Injects mobile price badges into desktop property page room rows
     * @param {Map<string, object>} priceMap - Map of block IDs to price info
     */
    function injectRoomPrices(priceMap) {
        if (priceMap.size === 0) {
            log('No mobile room prices to inject');
            return;
        }
        
        log('Injecting mobile prices into room offerings...');
        
        const roomRows = document.querySelectorAll(CONFIG.selectors.propertyRoomRow);
        log(`Found ${roomRows.length} room rows on desktop page`);
        
        // If no rows found, log helpful diagnostic info
        if (roomRows.length === 0) {
            const dataBlockElements = document.querySelectorAll('[data-block-id]');
            log(`⚠️ No room rows found with selector '${CONFIG.selectors.propertyRoomRow}'`);
            log(`Found ${dataBlockElements.length} elements with data-block-id attribute on page`);
            if (dataBlockElements.length > 0) {
                log(`First element with data-block-id: <${dataBlockElements[0].tagName.toLowerCase()}>`);
            }
            return;
        }
        
        let injectedCount = 0;
        
        roomRows.forEach((row, index) => {
            try {
                const blockId = row.getAttribute('data-block-id');
                if (!blockId) return;
                
                // Check if we have mobile price data for this room
                const mobileData = priceMap.get(blockId);
                if (!mobileData) return;
                
                if (row.querySelector(`.${CONFIG.badgeClassName}`)) return;
                
                const priceCell = row.querySelector(CONFIG.selectors.propertyPriceCell);
                if (!priceCell) {
                    log(`⚠️ No price cell found for block ${blockId}`);
                    return;
                }
                
                const badge = createBadgeElement(mobileData.price, blockId, 'data-block-id');
                
                const taxesText = priceCell.querySelector('.prd-taxes-and-fees-under-price');
                if (taxesText?.parentElement) {
                    taxesText.parentElement.insertBefore(badge, taxesText);
                } else {
                    const fallbackTarget = row.querySelector(CONFIG.selectors.propertyPriceBlock) || priceCell;
                    if (fallbackTarget) {
                        fallbackTarget.appendChild(badge);
                    } else {
                        log(`⚠️ Could not find injection point for block ${blockId}`);
                        return;
                    }
                }
                
                injectedCount++;
                log(`✓ Injected mobile price for block ${blockId}: ${mobileData.price}`);
                
            } catch (error) {
                logError(`Error injecting price for row ${index}:`, error);
            }
        });
        
        log(`Successfully injected ${injectedCount} mobile price badges on property page`);
    }
    
    /**
     * Finds the price section in a hotel card
     * @param {Element} card - The hotel card element
     * @returns {Element|null} The price section element
     */
    function findPriceSection(card) {
        const priceElement = card.querySelector('[data-testid="price-and-discounted-price"]');
        
        // Try to find a named price container via closest(), but ensure
        // we don't walk outside the card boundary
        let priceSection = priceElement?.closest('div[data-testid*="price"]');
        if (priceSection && !card.contains(priceSection)) {
            priceSection = null;
        }
        
        if (!priceSection) {
            const fallback = priceElement || card.querySelector('[data-testid="price"]');
            priceSection = fallback?.parentElement?.parentElement;
        }
        
        return priceSection;
    }
    
    // ============================================================================
    // DYNAMIC CONTENT OBSERVER
    // ============================================================================
    
    /**
     * Sets up MutationObserver to watch for dynamically loaded hotel cards and badge removal
     * @param {Map<string, object>} initialPriceMap - Initial price map to cache
     * @returns {object} Object with observer and disconnect function
     */
    function setupDynamicContentObserver(initialPriceMap) {
        log('Setting up MutationObserver for dynamic content...');
        
        let cachedPriceMap = initialPriceMap || new Map();
        
        const reinjectPrices = () => cachedPriceMap.size > 0 && injectMobilePrices(cachedPriceMap);
        const debouncedReinject = debounce(reinjectPrices, CONFIG.reinjectDelay);
        
        const debouncedProcess = debounce(async () => {
            try {
                const mobileHtml = await fetchMobilePage(window.location.href);
                cachedPriceMap = parseMobilePrices(mobileHtml);
                injectMobilePrices(cachedPriceMap);
            } catch (error) {
                logError('Failed to refetch mobile page:', error);
            }
        }, CONFIG.debounceDelay);
        
        const observer = new MutationObserver((mutations) => {
            const { shouldReinject, shouldReprocess } = analyzeMutations(mutations, CONFIG.selectors.hotelCard);
            
            if (shouldReprocess) {
                log('New hotel cards detected, reprocessing...');
                debouncedProcess();
            } else if (shouldReinject) {
                log('Badges removed, re-injecting...');
                debouncedReinject();
            }
        });
        
        // Observe the main content area
        const targetNode = document.body;
        if (targetNode) {
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            log('MutationObserver started');
        } else {
            logError('Could not find target node for MutationObserver');
        }
        
        return {
            observer,
            disconnect: () => observer.disconnect()
        };
    }
    
    /**
     * Sets up MutationObserver for property pages to watch for new room offerings
     * @param {Map<string, object>} initialPriceMap - Initial price map to cache
     * @returns {object} Object with observer and disconnect function
     */
    function setupPropertyPageObserver(initialPriceMap) {
        log('Setting up MutationObserver for property page...');
        
        let cachedPriceMap = initialPriceMap || new Map();
        
        const reinjectPrices = () => cachedPriceMap.size > 0 && injectRoomPrices(cachedPriceMap);
        const debouncedReinject = debounce(reinjectPrices, CONFIG.reinjectDelay);
        
        const debouncedProcess = debounce(async () => {
            try {
                const mobileHtml = await fetchMobilePage(window.location.href);
                cachedPriceMap = parseMobileRoomPrices(mobileHtml);
                injectRoomPrices(cachedPriceMap);
            } catch (error) {
                logError('Failed to refetch mobile page:', error);
            }
        }, CONFIG.debounceDelay);
        
        const observer = new MutationObserver((mutations) => {
            const { shouldReinject, shouldReprocess } = analyzeMutations(mutations, CONFIG.selectors.propertyRoomRow);
            
            if (shouldReprocess) {
                log('New room rows detected, reprocessing...');
                debouncedProcess();
            } else if (shouldReinject) {
                log('Badges removed, re-injecting...');
                debouncedReinject();
            }
        });
        
        const targetNode = document.body;
        if (targetNode) {
            observer.observe(targetNode, {
                childList: true,
                subtree: true
            });
            log('Property page MutationObserver started');
        }
        
        return {
            observer,
            disconnect: () => observer.disconnect()
        };
    }

    // ============================================================================
    // INITIALIZATION
    // ============================================================================
    
    // Track current observer for cleanup
    let currentObserver = null;
    let stylesInjected = false;
    let currentUrl = location.href;
    let currentPageType = detectPageType();
    
    /**
     * Main initialization function
     */
    async function init() {
        log('Initializing Booking.com Mobile Price Viewer...');
        
        const pageType = detectPageType();
        if (pageType === 'unknown') {
            log('Not a supported page type, exiting');
            return;
        }
        
        log(`Detected page type: ${pageType}`);
        
        // Inject CSS styles (only once)
        if (!stylesInjected) {
            GM_addStyle(CONFIG.style);
            stylesInjected = true;
            log('CSS styles injected');
        }
        
        // Disconnect any existing observer before waiting, to prevent
        // redundant fetches from the old observer during the delay
        if (currentObserver) {
            log('Disconnecting previous observer...');
            currentObserver.disconnect();
            currentObserver = null;
        }
        
        // Wait for page to be fully loaded
        if (document.readyState === 'loading') {
            await new Promise(resolve => {
                document.addEventListener('DOMContentLoaded', resolve);
            });
        }
        
        // Give the page a moment to render
        await new Promise(resolve => setTimeout(resolve, CONFIG.initialWaitTime));
        
        // Fetch mobile page once and use for both initial injection and observer
        try {
            const mobileHtml = await fetchMobilePage(window.location.href);
            
            // Process based on page type
            if (pageType === 'search') {
                const priceMap = parseMobilePrices(mobileHtml);
                injectMobilePrices(priceMap);
                currentObserver = setupDynamicContentObserver(priceMap);
            } else if (pageType === 'property') {
                const priceMap = parseMobileRoomPrices(mobileHtml);
                injectRoomPrices(priceMap);
                currentObserver = setupPropertyPageObserver(priceMap);
            }
        } catch (error) {
            logError('Failed to fetch and process mobile page:', error);
        }
        
        log('Initialization complete');
    }
    
    /**
     * Detects SPA navigation and reinitializes when needed.
     * Intercepts pushState/replaceState and listens for popstate to
     * detect navigation immediately, with a polling fallback for edge cases.
     */
    function setupSPANavigationWatcher() {
        log('Setting up SPA navigation watcher...');
        
        function onNavigate() {
            if (location.href === currentUrl) return;
            
            const newPageType = detectPageType();
            log(`URL changed from ${currentUrl} to ${location.href}`);
            log(`Page type: ${currentPageType} -> ${newPageType}`);
            
            currentUrl = location.href;
            
            if (newPageType !== 'unknown') {
                currentPageType = newPageType;
                log('URL changed, reinitializing...');
                init().catch(err => logError('Reinitialization failed:', err));
            }
        }
        
        // Intercept history.pushState and history.replaceState on the page's
        // actual history object. In sandboxed environments (when @grant is used),
        // `window` is a proxy — we must use unsafeWindow to ensure page scripts'
        // calls to pushState/replaceState are intercepted.
        try {
            const pageHistory = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).history;
            for (const method of ['pushState', 'replaceState']) {
                const original = pageHistory[method].bind(pageHistory);
                pageHistory[method] = function(...args) {
                    original(...args);
                    onNavigate();
                };
            }
        } catch (e) {
            log('Could not intercept history methods, relying on polling fallback');
        }
        
        // Listen for back/forward navigation
        window.addEventListener('popstate', onNavigate);
        
        // Polling fallback for edge cases (e.g., hash changes, other scripts
        // overriding history methods after us)
        setInterval(onNavigate, CONFIG.spaCheckInterval);
    }
    
    // Start the script
    init().catch(error => {
        logError('Initialization failed:', error);
    });
    
    // Set up SPA navigation watcher
    setupSPANavigationWatcher();

})();