All-in-One Hotel Price Calculator

Calculates the true per-night price (including taxes/fees) on Hotels.com and Booking.com search results and hotel pages.

// ==UserScript==
// @name         All-in-One Hotel Price Calculator
// @namespace    http://tampermonkey.net/
// @version      2.7.0
// @description  Calculates the true per-night price (including taxes/fees) on Hotels.com and Booking.com search results and hotel pages.
// @author       Gemini vibe bro
// @license      MIT
// @match        *://*.hotels.com/Hotel-Search*
// @match        *://*.hotels.com/d/*
// @match        *://*.hotels.com/ho*
// @match        *://*.booking.com/searchresults.html*
// @match        *://*.booking.com/hotel/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    /**
     * This script calculates the final, all-in price for hotel listings and displays it,
     * along with the true nightly price. It supports multiple travel websites and page types.
     */

    // --- SITE CONFIGURATIONS ---
    const SITE_CONFIGS = {
        'hotels.com-searchResults': {
            selectors: {
                hotelListingContainer: 'div.uitk-card.uitk-card-has-border, [data-testid="property-listing"], #floating-lodging-card',
                totalPrice: 'div.uitk-text.is-visually-hidden, [data-testid="price-and-discounted-price"]',
                perNightPriceDisplay: 'div.uitk-text.uitk-type-end, [data-testid="price-per-night"] > span',
            },
            dateParams: {
                start: ['startDate', 'd1'],
                end: ['endDate', 'd2']
            },
            processListing: processHotelsComListing
        },
        'hotels.com-hotelPage': {
            selectors: {
                hotelListingContainer: '[data-stid^="property-offer"]',
                totalPrice: 'div.uitk-text.is-visually-hidden, [data-testid="price-and-discounted-price"]',
                perNightPriceDisplay: 'div.uitk-text.uitk-type-end, [data-testid="price-per-night"] > span',
            },
            dateParams: {
                start: ['chkin'],
                end: ['chkout']
            },
            processListing: processHotelsComListing
        },
        'booking.com-searchResults': {
            selectors: {
                hotelListingContainer: '[data-testid="property-card"], [data-testid="property-list-map-card"]',
                basePrice: '[data-testid="price-and-discounted-price"]',
                taxesAndFees: '[data-testid="taxes-and-charges"]',
                priceContainer: '[data-testid="availability-rate-information"]',
            },
            dateParams: {
                start: ['checkin'],
                end: ['checkout']
            },
            processListing: processBookingComListing
        },
        'booking.com-hotelPage': {
             selectors: {
                hotelListingContainer: 'tr.js-rt-block-row',
                basePrice: '.bui-price-display__value .prco-valign-middle-helper',
                taxesAndFees: '.prd-taxes-and-fees-under-price',
                priceContainer: '.hprt-table-cell-price',
                strikethroughPrice: '.js-strikethrough-price'
            },
            dateParams: {
                start: ['checkin'],
                end: ['checkout']
            },
            processListing: processBookingComListing // The same logic works for both
        }
    };
    // ---------------------------------------------------------

    function parsePrice(text) {
        if (!text) return NaN;
        // This regex is more robust for different currency formats like US$XXX or XXX€
        const cleanText = text.replace(/[^\d.,]/g, '').replace(',', '.');
        const priceMatch = cleanText.match(/(\d+\.?\d*)/);
        return priceMatch ? parseFloat(priceMatch[0]) : NaN;
    }


    function calculateNightsFromUrl(config) {
        try {
            const urlParams = new URLSearchParams(window.location.search);
            let startDateStr, endDateStr;

            for (const param of config.dateParams.start) {
                if (urlParams.has(param)) {
                    startDateStr = urlParams.get(param);
                    break;
                }
            }
            for (const param of config.dateParams.end) {
                if (urlParams.has(param)) {
                    endDateStr = urlParams.get(param);
                    break;
                }
            }

            if (!startDateStr || !endDateStr) {
                console.warn("Price Calculator: Could not find start or end date in URL.");
                return NaN;
            }

            const startDate = new Date(startDateStr + 'T00:00:00Z');
            const endDate = new Date(endDateStr + 'T00:00:00Z');
            const timeDiff = endDate.getTime() - startDate.getTime();
            const nights = Math.round(timeDiff / (1000 * 60 * 60 * 24));

            return (nights > 0) ? nights : NaN;
        } catch (error) {
            console.error("Price Calculator: Error calculating nights from URL.", error);
            return NaN;
        }
    }

    /**
     * Site-Specific Processing Logic
     */

    function processHotelsComListing(listing, totalNights, config) {
        let perNightPriceDisplayEl = null;
        const potentialPerNightEls = listing.querySelectorAll(config.selectors.perNightPriceDisplay);
        for (const el of potentialPerNightEls) {
            if (el.innerText.toLowerCase().includes('nightly')) {
                perNightPriceDisplayEl = el;
                break;
            }
        }

        let totalPriceEl = null;
        const potentialTotalPriceEls = listing.querySelectorAll(config.selectors.totalPrice);
        for (const el of potentialTotalPriceEls) {
             // Find the element specifically containing "current price" for accuracy
            if (el.innerText.toLowerCase().includes('current price')) {
                totalPriceEl = el;
                break;
            }
        }

        if (!totalPriceEl || !perNightPriceDisplayEl || perNightPriceDisplayEl.title.includes('true per-night price')) {
            return false; // Not enough info or already processed
        }

        // Find and remove the strikethrough price element to declutter the UI
        const strikethroughEl = listing.querySelector('del');
        if (strikethroughEl) {
            const strikethroughContainer = strikethroughEl.closest('button[data-stid="disclaimer-dialog-link"]');
            if (strikethroughContainer) {
                strikethroughContainer.remove();
            }
        }

        const totalPriceText = totalPriceEl.innerText;
        const finalPrice = parsePrice(totalPriceText);
        if (isNaN(finalPrice)) return false;

        const truePerNightPrice = finalPrice / totalNights;
        const currencySymbol = totalPriceText.trim().match(/[$,€,£]/) ? totalPriceText.trim().match(/[$,€,£]/)[0] : '$';
        perNightPriceDisplayEl.innerText = `${currencySymbol}${truePerNightPrice.toFixed(0)} nightly`;

        Object.assign(perNightPriceDisplayEl.style, {
            color: '#008000', fontWeight: 'bold', border: '1px solid #008000',
            padding: '2px 4px', borderRadius: '4px'
        });
        perNightPriceDisplayEl.title = 'This is the true per-night price including all taxes and fees.';
        return true;
    }

    function processBookingComListing(listing, totalNights, config) {
        const basePriceEl = listing.querySelector(config.selectors.basePrice);
        const taxesEl = listing.querySelector(config.selectors.taxesAndFees);
        const priceContainer = listing.querySelector(config.selectors.priceContainer);
        const strikethroughEl = listing.querySelector(config.selectors.strikethroughPrice);

        // Check if we have the necessary elements or if we've already processed this listing
        if (!basePriceEl || !taxesEl || !priceContainer || priceContainer.querySelector('.true-price-container')) {
            return false;
        }

        // 1. Calculate prices from the original elements
        const basePrice = parsePrice(basePriceEl.innerText);
        const taxes = parsePrice(taxesEl.innerText);
        if (isNaN(basePrice) || isNaN(taxes)) return false;

        const totalPrice = basePrice + taxes;
        const perNightPrice = totalPrice / totalNights;
        const currencySymbol = (basePriceEl.innerText.trim().match(/[^0-9.,\s]+/) || ['$'])[0];

        // 2. Build the HTML for the hover popup using original text
        let popupHTML = '<div style="font-size: 0.9em; text-align: left;"><strong>Original Breakdown</strong><br>';
        if (strikethroughEl) {
            popupHTML += `Original: <del>${strikethroughEl.innerText}</del><br>`;
        }
        popupHTML += `Base Price: ${basePriceEl.innerText}<br>`;
        popupHTML += `Taxes & Fees: ${taxesEl.innerText}`;
        popupHTML += '</div>';

        // 3. Create the popup element
        const popupEl = document.createElement('div');
        popupEl.className = 'true-price-popup';
        popupEl.innerHTML = popupHTML;
        Object.assign(popupEl.style, {
            display: 'none',
            position: 'absolute',
            bottom: '100%',
            right: '0',
            marginBottom: '5px',
            backgroundColor: 'white',
            border: '1px solid #ccc',
            padding: '8px',
            borderRadius: '5px',
            zIndex: '1000',
            whiteSpace: 'nowrap',
            boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
        });

        // 4. Create the new container that will be visible
        const displayContainer = document.createElement('div');
        displayContainer.className = 'true-price-container'; // New class to check against
        displayContainer.style.position = 'relative'; // Anchor for the popup
        Object.assign(displayContainer.style, {
            marginTop: '4px',
            padding: '4px',
            border: '2px solid #008000',
            borderRadius: '4px',
            backgroundColor: '#f0fff0',
            textAlign: 'right',
            cursor: 'pointer'
        });
        displayContainer.innerHTML = `
            <div style="font-weight: bold; color: #006400;">${currencySymbol}${totalPrice.toFixed(0)} total</div>
            <div style="font-size: 0.9em; color: #333;">${currencySymbol}${perNightPrice.toFixed(0)} / night</div>
        `;

        // 5. Add event listeners to the new container to show/hide the popup
        displayContainer.addEventListener('mouseenter', () => { popupEl.style.display = 'block'; });
        displayContainer.addEventListener('mouseleave', () => { popupEl.style.display = 'none'; });

        // 6. Append the hidden popup to the new container
        displayContainer.appendChild(popupEl);

        // 7. Hide the original price elements
        Array.from(priceContainer.children).forEach(child => {
            if (child.style) {
                 child.style.display = 'none';
            }
        });

        // 8. Add the new interactive element to the page
        priceContainer.appendChild(displayContainer);

        return true;
    }


    /**
     * Main script execution and observation logic
     */
    function updateHotelPrices() {
        const hostname = window.location.hostname;
        const pathname = window.location.pathname;
        let siteKey = null;

        if (hostname.includes('hotels.com')) {
            if (pathname.startsWith('/Hotel-Search')) {
                 siteKey = 'hotels.com-searchResults';
            } else if (pathname.startsWith('/ho') || pathname.startsWith('/d/')) {
                 siteKey = 'hotels.com-hotelPage';
            }
        } else if (hostname.includes('booking.com')) {
            if (pathname.startsWith('/searchresults')) {
                siteKey = 'booking.com-searchResults';
            } else if (pathname.startsWith('/hotel/')) {
                siteKey = 'booking.com-hotelPage';
            }
        }

        if (!siteKey) return;
        const siteConfig = SITE_CONFIGS[siteKey];

        const totalNights = calculateNightsFromUrl(siteConfig);
        if (isNaN(totalNights)) {
            console.error("Price Calculator: Could not determine number of nights. Aborting.");
            return;
        }

        const listings = document.querySelectorAll(siteConfig.selectors.hotelListingContainer);
        if (listings.length === 0) return;

        let successfulUpdates = 0;
        listings.forEach((listing) => {
            try {
                if (siteConfig.processListing(listing, totalNights, siteConfig)) {
                    successfulUpdates++;
                }
            } catch (error) {
                console.error("Price Calculator: An error occurred while processing a listing:", error, listing);
            }
        });

        if (successfulUpdates > 0) {
            console.log(`Price Calculator: Finished. Successfully updated ${successfulUpdates} of ${listings.length} listings on ${siteKey}.`);
        }
    }

    function debounce(func, delay) {
        let timeout;
        return (...args) => {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), delay);
        };
    }

    const debouncedUpdate = debounce(updateHotelPrices, 750);
    const observer = new MutationObserver(() => debouncedUpdate());
    observer.observe(document.body, { childList: true, subtree: true });

    // Initial run after page load
    setTimeout(updateHotelPrices, 2000);
})();