// ==UserScript== // @name StayValue // @namespace https://github.com/chaoxu/stayvalue // @version 2.7.0 // @description Compare hotel point rates vs cash rates - shows cents-per-point (cpp) and highlights better value // @match https://www.ihg.com/* // @match https://www.marriott.com/* // @match https://www.hyatt.com/* // @match https://www.hilton.com/* // @updateURL https://raw.githubusercontent.com/chaoxu/stayvalue/main/stayvalue.user.js // @downloadURL https://raw.githubusercontent.com/chaoxu/stayvalue/main/stayvalue.user.js // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect api.exchangerate-api.com // @connect www.hyatt.com // @require https://update.greasyfork.org/scripts/515994/1478507/gh_2215_make_GM_xhr_more_parallel_again.js // @run-at document-start // ==/UserScript== (function() { 'use strict'; // ============================================ // USER CONFIGURATION // ============================================ // Global settings (same across all chains) const GLOBAL_CONFIG = { dollarDecimals: GM_getValue('dollarDecimals', 0), iataCode: GM_getValue('iataCode', '') }; // Chain-specific config defaults const CHAIN_CONFIG_DEFAULTS = { IHG: { pointValue: 0.5, cashbackRate: 0.05, travelAgentRebateRate: 0.07 }, Marriott: { pointValue: 0.7, cashbackRate: 0.05, travelAgentRebateRate: 0.03 }, Hyatt: { pointValue: 1.5, cashbackRate: 0.05, travelAgentRebateRate: 0 }, Hilton: { pointValue: 0.4, cashbackRate: 0.05, travelAgentRebateRate: 0 } }; // Get chain-specific config value function getChainConfig(chainName) { const defaults = CHAIN_CONFIG_DEFAULTS[chainName] || CHAIN_CONFIG_DEFAULTS.IHG; const prefix = chainName.toLowerCase(); return { pointValue: GM_getValue(`${prefix}_pointValue`, defaults.pointValue), cashbackRate: GM_getValue(`${prefix}_cashbackRate`, defaults.cashbackRate), travelAgentRebateRate: GM_getValue(`${prefix}_travelAgentRebateRate`, defaults.travelAgentRebateRate) }; } const CONFIG = { debug: true }; // ============================================ // HOTEL CHAIN ADAPTERS // ============================================ // Base adapter with shared default implementations const BaseAdapter = { chainConfig: null, defaultEliteStatus: 'Member', bonusPointsRates: {}, // Default: find price container by iterating selectors findPriceContainer(card) { for (const selector of this.selectors.priceContainer) { const container = card.querySelector(selector); if (container) return container; } return null; }, // Default: calculate points per dollar based on brand and elite level getPointsPerDollar(brandCode, eliteLevel) { const basePoints = this.brandBasePoints[brandCode] || this.brandBasePoints['default']; const bonusRate = this.eliteBonusRates[eliteLevel] ?? 0; return basePoints * (1 + bonusRate); }, // Default: no bonus points for rate codes getBonusPoints(rateCode) { return this.bonusPointsRates[rateCode] || 0; }, // Default: no currency conversion parsing parseCurrencyResponse(data) { return null; }, // Default: URL-based API type detection getApiType(url) { if (this.apiPatterns.availability && url.includes(this.apiPatterns.availability)) return 'availability'; if (this.apiPatterns.profile && url.includes(this.apiPatterns.profile)) return 'profile'; if (this.apiPatterns.currency && url.includes(this.apiPatterns.currency)) return 'currency'; return null; }, // Default: no special response handling handleResponse(data, url) { return null; } }; // Helper to create adapter by extending base function createAdapter(config) { return Object.assign(Object.create(BaseAdapter), config); } const IHGAdapter = createAdapter({ name: 'IHG', programName: 'IHG One Rewards', match: /ihg\.com/, brandBasePoints: { 'default': 10, 'CDLW': 5, // Candlewood Suites 'STAY': 5 // Staybridge Suites }, eliteBonusRates: { 'CLUB': 0, 'SILVER': 0.2, 'GOLD': 0.4, 'PLATINUM': 0.6, 'DIAMOND': 1.0 }, defaultEliteStatus: 'DIAMOND', bonusPointsRates: { 'IKBIZ': 1000, 'IKPCM': 1000, 'IKME3': 1000, 'IKME4': 1000, 'IKME6': 2000, 'IKME7': 2000, 'IKME8': 3000, 'IKME9': 3000, 'IKM5K': 5000 }, selectors: { hotelCard: 'app-hotel-card-list-view', priceContainer: ['app-hotel-point', 'app-hotel-cash'], iataInput: 'input[name="iata"]' }, apiPatterns: { availability: 'apis.ihg.com/availability/v3/hotels/offers', profile: 'apis.ihg.com/members/v2/profiles/me', currency: 'apis.ihg.com/finance/conversions/v2/currencies' }, getHotelCodeFromCard(card) { return card.id || card.getAttribute('data-testid')?.replace('hotel-card-', ''); }, // Override: IHG uses CLUB as default elite bonus getPointsPerDollar(brandCode, eliteLevel) { const basePoints = this.brandBasePoints[brandCode] || this.brandBasePoints['default']; const bonusRate = this.eliteBonusRates[eliteLevel] ?? this.eliteBonusRates['CLUB']; return basePoints * (1 + bonusRate); }, parseAvailabilityResponse(data) { if (!data?.hotels || !Array.isArray(data.hotels)) { return []; } return data.hotels.map(hotel => { const hotelData = { hotelCode: hotel.hotelMnemonic, brandCode: hotel.brandCode, currency: hotel.propertyCurrency, lowestCash: null, lowestPoints: null, ratePlans: [] }; if (hotel.lowestCashOnlyCost) { hotelData.lowestCash = { total: hotel.lowestCashOnlyCost.amountAfterTax, roomRate: hotel.lowestCashOnlyCost.baseAmount, fees: hotel.lowestCashOnlyCost.excludedFeeSubTotal || 0, taxes: hotel.lowestCashOnlyCost.excludedTaxSubTotal || 0 }; } if (hotel.lowestPointsOnlyCost) { hotelData.lowestPoints = hotel.lowestPointsOnlyCost.points; } if (hotel.ratePlanDefinitions && Array.isArray(hotel.ratePlanDefinitions)) { hotelData.ratePlans = hotel.ratePlanDefinitions .filter(plan => plan.rateRange?.low) .map(plan => ({ code: plan.code, total: plan.rateRange.low.amountAfterTax, roomRate: plan.rateRange.low.baseAmount, fees: plan.rateRange.low.excludedFeeSubTotal || 0, taxes: plan.rateRange.low.excludedTaxSubTotal || 0 })); } return hotelData; }).filter(h => h.hotelCode); }, // Parse profile API response parseProfileResponse(data) { const profile = { eliteLevel: this.defaultEliteStatus, pointsBalance: null }; if (data?.programs && Array.isArray(data.programs)) { const pcProgram = data.programs.find(p => p.programCode === 'PC'); if (pcProgram) { profile.eliteLevel = pcProgram.levelCode || this.defaultEliteStatus; profile.pointsBalance = pcProgram.currentPointsBalance; profile.levelDescription = pcProgram.levelDescription; } } return profile; }, // IHG has its own currency API parseCurrencyResponse(data) { if (!data?.fromCurrency || !data?.toCurrency || !data?.results) return null; const pResult = data.results.find(r => r.source === 'P'); if (!pResult) return null; return { from: data.fromCurrency.code, to: data.toCurrency.code, rate: pResult.result }; } }); const MarriottAdapter = createAdapter({ name: 'Marriott', programName: 'Marriott Bonvoy', match: /marriott\.com/, brandBasePoints: { 'default': 10, 'RI': 5, 'TS': 5, 'EL': 5, 'EX': 5, // Extended stay (5 pts/$) 'HV': 2.5 // Homes & Villas }, eliteBonusRates: { 'Member': 0, 'Silver': 0.1, 'Gold': 0.25, 'Platinum': 0.5, 'Titanium': 0.75, 'Ambassador': 0.75 }, defaultEliteStatus: 'Platinum', selectors: { hotelCard: '.property-card-container', priceContainer: ['div.rate-container', 'div.price-sub-section'], iataInput: 'input#iata[data-testid="iata"]' }, apiPatterns: { availability: 'marriott.com/mi/query/phoenixShopDatedSearchByGeoQuery', profile: 'marriott.com/hybrid-presentation/api/v1/getUserDetails', currency: null }, getHotelCodeFromCard(card) { const link = card.querySelector('a[href*="propertyCode="]'); if (link) { const match = link.href.match(/propertyCode=([A-Z0-9]+)/i); if (match) return match[1]; } return card.getAttribute('data-property-code') || card.getAttribute('data-marsha-code') || null; }, // Marriott amount parsing: {amount: 1000, decimalPoint: 2} = 10.00 parseAmount(amountObj) { if (!amountObj || amountObj.amount === undefined) return 0; const amount = amountObj.amount; const decimalPoint = amountObj.decimalPoint || 0; return amount / Math.pow(10, decimalPoint); }, // Parse availability API response (GraphQL) parseAvailabilityResponse(data) { // Navigate to edges array - supports both searchByGeolocation and searchByLocation const edges = data?.data?.search?.lowestAvailableRates?.searchByGeolocation?.edges || data?.data?.search?.lowestAvailableRates?.searchByLocation?.edges; if (!edges || !Array.isArray(edges)) { return []; } return edges.map(edge => { const node = edge.node; if (!node?.property?.id) return null; const property = node.property; const basicInfo = property.basicInformation || {}; const rates = node.rates || []; const hotelData = { hotelCode: property.id, brandCode: basicInfo.brand?.id || 'default', brandName: basicInfo.brand?.name || '', currency: basicInfo.currency || 'USD', hotelName: basicInfo.name || '', lowestCash: null, lowestPoints: null, lengthOfStay: null, ratePlans: [] }; // Process rates for (const rate of rates) { if (rate.status?.code !== 'AvailableForSale') continue; const lengthOfStay = rate.lengthOfStay || 1; hotelData.lengthOfStay = lengthOfStay; // Points rate if (rate.rateModes?.pointsPerUnit?.points) { const totalPoints = rate.rateModes.pointsPerUnit.points; // Convert to per-night points hotelData.lowestPoints = Math.round(totalPoints / lengthOfStay); } // Cash rate (StandardRates) if (rate.rateCategory?.code === 'StandardRates' && rate.rateModes?.lowestAverageRate) { const cashRate = rate.rateModes.lowestAverageRate; hotelData.lowestCash = { // Per night values - parse with decimal point handling roomRate: this.parseAmount(cashRate.amount), fees: this.parseAmount(cashRate.fees), mandatoryFees: this.parseAmount(cashRate.mandatoryFees), taxes: this.parseAmount(cashRate.taxes), total: this.parseAmount(cashRate.totalAmount), amountPlusMandatoryFees: this.parseAmount(cashRate.amountPlusMandatoryFees) }; } } return hotelData; }).filter(h => h !== null && h.hotelCode); }, // Parse profile API response parseProfileResponse(data) { const profile = { eliteLevel: this.defaultEliteStatus, pointsBalance: null, levelDescription: null }; if (data?.status === 'success' && data?.userProfileSummary) { const summary = data.userProfileSummary; // Extract elite level (e.g., "Titanium Elite" -> "Titanium") if (summary.level) { profile.levelDescription = summary.level; // Remove " Elite" suffix to match our eliteBonusRates keys const levelKey = summary.level.replace(/\s*Elite$/i, ''); if (this.eliteBonusRates[levelKey] !== undefined) { profile.eliteLevel = levelKey; } } // Extract points balance (e.g., "19,799" -> 19799) if (summary.currentPoints) { const points = parseInt(summary.currentPoints.replace(/,/g, ''), 10); if (!isNaN(points)) { profile.pointsBalance = points; } } } return profile; } }); const HyattAdapter = createAdapter({ name: 'Hyatt', programName: 'World of Hyatt', match: /hyatt\.com/, brandBasePoints: { 'default': 5, 'Hyatt Studios': 2.5 }, eliteBonusRates: { 'Member': 0, 'Discoverist': 0.1, 'Explorist': 0.2, 'Globalist': 0.3 }, defaultEliteStatus: 'Globalist', selectors: { hotelCard: '[class*="HotelCard_info_section_rate_content"]', priceContainer: ['[class*="HotelCard_rates"]', '.rates'], iataInput: 'input[name="travel-agent-id"]' }, apiPatterns: { availability: null, profile: null, currency: null }, // Room types to check in order of preference roomTypes: ['STANDARD_ROOM', 'CLUB', 'STANDARD_SUITE', 'PREMIUM_SUITE'], getHotelCodeFromCard(card) { const link = card.querySelector('a[href*="/shop/rooms/"]'); if (link) { const match = link.href.match(/\/shop\/rooms\/([a-z0-9]+)/i); if (match) return match[1].toUpperCase(); } return null; }, findPriceContainer(card) { for (const selector of this.selectors.priceContainer) { const container = card.querySelector(selector); if (container) return container; } return card; // Fallback to card itself }, getApiType(url) { return null; // Hyatt uses JSON.parse interception, not URL-based }, parseProfileResponse(data) { return { eliteLevel: this.defaultEliteStatus, pointsBalance: null }; }, // Get search parameters from URL getSearchParams() { const url = new URL(location.href); const checkinDate = url.searchParams.get('checkinDate'); const checkoutDate = url.searchParams.get('checkoutDate'); if (!checkinDate || !checkoutDate) return null; const checkin = new Date(checkinDate); const checkout = new Date(checkoutDate); const nights = Math.round((checkout - checkin) / (1000 * 60 * 60 * 24)); return { checkinDate, checkoutDate, nights }; }, parseAvailabilityResponse(data) { if (!data?.hotelSummaries || !Array.isArray(data.hotelSummaries)) return []; const searchParams = this.getSearchParams(); const nights = searchParams?.nights || 1; return data.hotelSummaries.map(hotel => { const detail = hotel.hotelDetail || {}; const rate = hotel.leadingRate || {}; if (!rate.spiritCode) return null; const hotelData = { hotelCode: rate.spiritCode.toUpperCase(), brandCode: detail.brand || 'default', brandName: detail.brandLabel || '', hotelName: detail.name || '', currency: rate.currencyCode || 'USD', lowestCash: null, lowestPoints: null, nights, pointsInfo: null, ratePlans: [] }; if (rate.rate && rate.status === 'BOOKABLE') { hotelData.lowestCash = { total: rate.rateAfterTax || rate.rate, roomRate: rate.rate, fees: 0, taxes: (rate.rateAfterTax || rate.rate) - rate.rate }; } if (rate.points) { hotelData.lowestPoints = rate.points; } return hotelData; }).filter(h => h !== null && h.hotelCode); }, // Calculate points from availability API response calculatePoints(data, checkinDate, nights) { if (!data?.days) return null; const roomsOnDate = data.days[checkinDate]; if (!roomsOnDate) return null; let lowestResult = null; for (const roomType of this.roomTypes) { if (!roomsOnDate[roomType]) continue; const roomData = roomsOnDate[roomType]; const pointsArray = Array.isArray(roomData.pointsValue) ? roomData.pointsValue : (roomData.pointsValue ? [roomData.pointsValue] : []); if (pointsArray.length < nights) continue; const totalPoints = pointsArray.slice(0, nights).reduce((sum, p) => sum + p, 0); const avgPerNight = Math.round(totalPoints / nights); if (lowestResult === null || avgPerNight < lowestResult.avgPerNight) { lowestResult = { totalPoints, avgPerNight, roomType }; } } return lowestResult; }, // Fetch availability for a specific hotel fetchAvailability(spiritCode, checkinDate, checkoutDate, nights) { return new Promise((resolve) => { const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail/days?spiritCode=${spiritCode}&startDate=${checkinDate}&endDate=${checkoutDate}&numAdults=1&numChildren=0&roomQuantity=1&los=${nights}&isMock=false`; GM_xmlhttpRequest({ method: 'GET', url: apiUrl, onload: (response) => { try { resolve({ spiritCode, data: JSON.parse(response.responseText), success: true }); } catch (e) { resolve({ spiritCode, data: null, success: false }); } }, onerror: () => resolve({ spiritCode, data: null, success: false }) }); }); }, // Extract data from Next.js RSC embedded scripts extractDataFromScripts(handleApiResponse, hotelCache, debouncedProcess) { log('Attempting to extract Hyatt data from page scripts...'); const scripts = document.querySelectorAll('script'); for (const script of scripts) { const text = script.textContent; if (!text || !text.includes('hotelSummaries')) continue; const hotelDataStart = text.indexOf('\\"hotelData\\":{'); if (hotelDataStart === -1) continue; let braceCount = 0; let startIdx = hotelDataStart + '\\"hotelData\\":'.length; let endIdx = startIdx; for (let i = startIdx; i < text.length; i++) { const char = text[i]; if (char === '\\' && text[i + 1]) { i++; continue; } if (char === '{') braceCount++; if (char === '}') { braceCount--; if (braceCount === 0) { endIdx = i + 1; break; } } } if (endIdx <= startIdx) continue; let jsonStr = text.substring(startIdx, endIdx) .replace(/\\"/g, '"') .replace(/\\\\/g, '\\'); try { const hotelData = JSON.parse(jsonStr); if (hotelData.hotelSummaries && Array.isArray(hotelData.hotelSummaries)) { log('Parsed', hotelData.hotelSummaries.length, 'hotels from Next.js RSC'); handleApiResponse('availability', hotelData); const searchParams = this.getSearchParams(); if (searchParams && searchParams.nights > 1) { this.fetchMultiDayRates(hotelData.hotelSummaries, hotelCache, debouncedProcess); } return; } } catch (e) { log('Parse failed:', e.message); } } log('Could not parse hotel data from scripts'); }, // Fetch multi-day rates for all hotels with staggered requests fetchMultiDayRates(hotelSummaries, hotelCache, debouncedProcess) { const searchParams = this.getSearchParams(); if (!searchParams || searchParams.nights <= 1) return; log('Multi-day search:', searchParams.nights, 'nights. Fetching accurate rates...'); const hotelsWithPoints = hotelSummaries.filter(h => h.leadingRate?.points != null); log('Hotels with points:', hotelsWithPoints.length, '/', hotelSummaries.length); const hotelCodes = hotelsWithPoints .map(h => h.spiritCode || h.hotelDetail?.spiritCode) .filter(Boolean); hotelCodes.forEach((spiritCode, index) => { setTimeout(() => { this.fetchAvailability( spiritCode, searchParams.checkinDate, searchParams.checkoutDate, searchParams.nights ).then(result => { if (!result.success || !result.data) return; const pointsInfo = this.calculatePoints( result.data, searchParams.checkinDate, searchParams.nights ); const hotelCode = spiritCode.toUpperCase(); const cached = hotelCache.get(hotelCode); if (cached?.data) { if (pointsInfo) { cached.data.lowestPoints = pointsInfo.avgPerNight; cached.data.pointsInfo = pointsInfo; log('Updated', hotelCode, ':', pointsInfo.avgPerNight, 'pts/night,', pointsInfo.totalPoints, 'total', '(' + pointsInfo.roomType + ')'); } else { cached.data.lowestPoints = null; cached.data.pointsInfo = null; log('Updated', hotelCode, ': no points availability'); } cached.data.nights = searchParams.nights; hotelCache.set(hotelCode, cached); const card = document.querySelector(`a[href*="/shop/rooms/${spiritCode}"]`) ?.closest('[class*="HotelCard_info_section_rate_content"]'); if (card) { card.removeAttribute('data-stayvalue-processed'); card.querySelector('.stayvalue-display')?.remove(); } debouncedProcess(); } }); }, index * (200 + Math.random() * 100)); }); } }); const HiltonAdapter = createAdapter({ name: 'Hilton', programName: 'Hilton Honors', match: /hilton\.com/, brandBasePoints: { 'default': 10, 'Home2 Suites': 5, 'Tru': 5, 'LivSmart Studios': 3 }, eliteBonusRates: { 'Member': 0, 'Silver': 0.2, 'Gold': 0.8, 'Diamond': 1.0 }, defaultEliteStatus: 'Diamond', selectors: { hotelCard: 'li[data-testid^="hotel-card-"]', priceContainer: ['[data-testid="priceInfo"]'], iataInput: null }, apiPatterns: { availability: 'hilton.com/graphql/customer', profile: 'operationName=callbackProfile', currency: null }, getHotelCodeFromCard(card) { const testId = card.getAttribute('data-testid'); if (testId && testId.startsWith('hotel-card-')) { const code = testId.replace('hotel-card-', ''); if (code && /^[A-Z0-9]+$/.test(code)) return code; } return null; }, findPriceContainer(card) { for (const selector of this.selectors.priceContainer) { const container = card.querySelector(selector); if (container) return container; } // Fallback selectors const fallbacks = [ '[class*="Price"]', '[class*="price"]', '[class*="rate"]', '[class*="Rate"]', '[data-testid*="price"]', '[data-testid*="rate"]' ]; for (const selector of fallbacks) { const container = card.querySelector(selector); if (container) return container; } return card; }, // Parse currency amount from formatted string (e.g., "¥53,393" -> 53393) parseCurrencyAmount(formatted) { if (!formatted) return null; const num = parseFloat(formatted.replace(/[^\d.]/g, '')); return isNaN(num) ? null : num; }, detectCurrency(formatted) { if (!formatted) return 'USD'; if (formatted.includes('¥')) return 'JPY'; if (formatted.includes('€')) return 'EUR'; if (formatted.includes('£')) return 'GBP'; return 'USD'; }, parseAvailabilityResponse(data) { const hotels = []; if (!data?.data?.shopMultiPropAvail) return hotels; for (const hotel of data.data.shopMultiPropAvail) { if (!hotel.ctyhocn || hotel.summary?.status?.type !== 'AVAILABLE') continue; const summary = hotel.summary || {}; const lowest = summary.lowest; const hhonors = summary.hhonors; if (!lowest) continue; const lengthOfStay = hotel.lengthOfStay || 1; const hotelData = { hotelCode: hotel.ctyhocn, brandCode: 'default', currency: hotel.currencyCode || this.detectCurrency(lowest.rateAmountFmt), lowestCash: null, lowestPoints: null, lengthOfStay, ratePlans: [] }; if (lowest.rateAmountFmt) { const roomRatePerNight = this.parseCurrencyAmount(lowest.rateAmountFmt); const totalAllNights = lowest.amountAfterTaxFmt ? this.parseCurrencyAmount(lowest.amountAfterTaxFmt) : null; if (roomRatePerNight) { const totalPerNight = totalAllNights ? totalAllNights / lengthOfStay : roomRatePerNight; const taxesPerNight = totalPerNight - roomRatePerNight; hotelData.lowestCash = { total: totalPerNight, roomRate: roomRatePerNight, fees: 0, taxes: taxesPerNight > 0 ? taxesPerNight : 0 }; } } if (hhonors?.dailyRmPointsRate) { hotelData.lowestPoints = hhonors.dailyRmPointsRate; } hotels.push(hotelData); } return hotels; }, parseProfileResponse(data) { const profile = { eliteLevel: this.defaultEliteStatus, pointsBalance: null }; const tierCode = data?.data?.callbackProfile?.guest?.hhonors?.summary?.tier; if (tierCode) { const tierMap = { 'M': 'Member', 'S': 'Silver', 'G': 'Gold', 'D': 'Diamond' }; profile.eliteLevel = tierMap[tierCode] || this.defaultEliteStatus; } return profile; }, // Hilton uses GraphQL - handle responses directly handleResponse(data) { if (data?.data?.shopMultiPropAvail) return 'availability'; if (data?.data?.callbackProfile?.guest?.hhonors) return 'profile'; return null; } }); // All adapters const ADAPTERS = [IHGAdapter, MarriottAdapter, HyattAdapter, HiltonAdapter]; // Detect current adapter based on URL function detectAdapter() { const hostname = location.hostname; for (const adapter of ADAPTERS) { if (adapter.match.test(hostname)) { // Load chain-specific config adapter.chainConfig = getChainConfig(adapter.name); return adapter; } } return null; } // Current active adapter const activeAdapter = detectAdapter(); // ============================================ // SHARED STATE // ============================================ let userProfile = { eliteLevel: activeAdapter?.defaultEliteStatus || 'Member', pointsBalance: null, levelDescription: null }; const currencyRates = new Map(); const hotelCache = new Map(); // ============================================ // MENU COMMANDS // ============================================ function setupMenuCommands() { if (!activeAdapter) return; const chainName = activeAdapter.name; const chainConfig = activeAdapter.chainConfig; const prefix = chainName.toLowerCase(); GM_registerMenuCommand(`[${chainName}] Set Point Value (current: ${chainConfig.pointValue}¢)`, () => { const value = prompt(`Enter your ${chainName} point valuation in cents (e.g., 0.5 for half a cent):`, chainConfig.pointValue); if (value !== null) { const num = parseFloat(value); if (!isNaN(num) && num >= 0) { GM_setValue(`${prefix}_pointValue`, num); alert(`${chainName} point value set to ${num}¢. Refresh the page to apply.`); } else { alert('Invalid value. Please enter a number >= 0.'); } } }); GM_registerMenuCommand(`[${chainName}] Set Cashback Rate (current: ${(chainConfig.cashbackRate * 100).toFixed(1)}%)`, () => { const value = prompt(`Enter your ${chainName} cashback rate as a percentage (e.g., 5 for 5%):`, chainConfig.cashbackRate * 100); if (value !== null) { const num = parseFloat(value) / 100; if (!isNaN(num) && num >= 0 && num <= 1) { GM_setValue(`${prefix}_cashbackRate`, num); alert(`${chainName} cashback rate set to ${(num * 100).toFixed(1)}%. Refresh the page to apply.`); } else { alert('Invalid value. Please enter a percentage between 0 and 100.'); } } }); GM_registerMenuCommand(`[${chainName}] Set TA Rebate (current: ${(chainConfig.travelAgentRebateRate * 100).toFixed(1)}%)`, () => { const value = prompt(`Enter ${chainName} travel agent rebate rate as a percentage (e.g., 7 for 7%, 0 to disable):`, chainConfig.travelAgentRebateRate * 100); if (value !== null) { const num = parseFloat(value) / 100; if (!isNaN(num) && num >= 0 && num <= 1) { GM_setValue(`${prefix}_travelAgentRebateRate`, num); alert(`${chainName} travel agent rebate set to ${(num * 100).toFixed(1)}%. Refresh the page to apply.`); } else { alert('Invalid value. Please enter a percentage between 0 and 100.'); } } }); GM_registerMenuCommand(`Set Dollar Decimals (current: ${GLOBAL_CONFIG.dollarDecimals})`, () => { const value = prompt('Enter number of decimal places for dollar amounts (0, 1, or 2):', GLOBAL_CONFIG.dollarDecimals); if (value !== null) { const num = parseInt(value, 10); if (!isNaN(num) && num >= 0 && num <= 2) { GM_setValue('dollarDecimals', num); alert(`Dollar decimals set to ${num}. Refresh the page to apply.`); } else { alert('Invalid value. Please enter 0, 1, or 2.'); } } }); GM_registerMenuCommand(`Set IATA Code (current: ${GLOBAL_CONFIG.iataCode || 'not set'})`, () => { const value = prompt('Enter your IATA code (e.g., 99634986):', GLOBAL_CONFIG.iataCode); if (value !== null) { const trimmed = value.trim(); if (trimmed.length <= 8) { GM_setValue('iataCode', trimmed); alert(`IATA code set to "${trimmed}". Refresh the page to apply.`); } else { alert('Invalid value. IATA code must be 8 characters or less.'); } } }); } // ============================================ // STORAGE FUNCTIONS // ============================================ function loadFromStorage() { try { // Load user profile const storedProfile = sessionStorage.getItem('stayvalue_profile'); if (storedProfile) { const data = JSON.parse(storedProfile); if (data.timestamp && Date.now() - data.timestamp < 3600000) { userProfile = { ...data.profile }; log('Loaded profile from storage:', userProfile); } } // Load currency rates const storedCurrency = sessionStorage.getItem('stayvalue_currencies'); if (storedCurrency) { const data = JSON.parse(storedCurrency); if (data.timestamp && Date.now() - data.timestamp < 86400000) { Object.entries(data.rates || {}).forEach(([key, value]) => { currencyRates.set(key, value); }); log('Loaded', currencyRates.size, 'currency rates from storage'); } } // Load hotel cache const storedHotels = sessionStorage.getItem('stayvalue_hotels'); if (storedHotels) { const data = JSON.parse(storedHotels); if (data.timestamp && Date.now() - data.timestamp < 3600000) { Object.entries(data.hotels || {}).forEach(([key, value]) => { hotelCache.set(key, value); }); log('Loaded', hotelCache.size, 'hotels from storage'); } } } catch (e) { log('Error loading from storage:', e); } } function saveProfileToStorage() { try { sessionStorage.setItem('stayvalue_profile', JSON.stringify({ profile: userProfile, timestamp: Date.now() })); } catch (e) { log('Error saving profile:', e); } } function saveCurrencyRatesToStorage() { try { sessionStorage.setItem('stayvalue_currencies', JSON.stringify({ rates: Object.fromEntries(currencyRates), timestamp: Date.now() })); } catch (e) { log('Error saving currency rates:', e); } } function saveHotelCacheToStorage() { try { sessionStorage.setItem('stayvalue_hotels', JSON.stringify({ hotels: Object.fromEntries(hotelCache), timestamp: Date.now() })); } catch (e) { log('Error saving hotel cache:', e); } } // ============================================ // NETWORK INTERCEPTION // ============================================ // Check if data matches known response patterns function detectResponseType(data) { if (!data || typeof data !== 'object') return null; // Marriott GraphQL if (data.data?.search?.lowestAvailableRates?.searchByGeolocation?.edges || data.data?.search?.lowestAvailableRates?.searchByLocation?.edges) { return 'availability'; } // Hyatt hotelSummaries if (data.hotelSummaries && Array.isArray(data.hotelSummaries)) { return 'availability'; } if (data.searchResults?.hotelSummaries && Array.isArray(data.searchResults.hotelSummaries)) { return 'availability'; } // Hilton GraphQL (use adapter's handleResponse) if (activeAdapter?.handleResponse) { const type = activeAdapter.handleResponse(data); if (type) return type; } return null; } // Normalize data for handleApiResponse function normalizeResponseData(data) { // Hyatt: if searchResults contains hotelSummaries, unwrap it if (data.searchResults?.hotelSummaries) { return data.searchResults; } return data; } function setupNetworkInterception() { if (!activeAdapter) return; // Method 1: Intercept fetch const originalFetch = window.fetch; window.fetch = async function(...args) { const url = args[0]?.url || args[0]; const response = await originalFetch.apply(this, args); if (typeof url === 'string') { // For GraphQL endpoints, check response data structure if (url.includes('graphql')) { try { const clone = response.clone(); const data = await clone.json(); const apiType = detectResponseType(data); if (apiType) { log('[fetch]', activeAdapter.name, apiType); handleApiResponse(apiType, normalizeResponseData(data)); } } catch (e) { /* ignore parse errors */ } } else { // URL-based detection const apiType = activeAdapter.getApiType(url); if (apiType) { try { const clone = response.clone(); const data = await clone.json(); handleApiResponse(apiType, data); } catch (e) { /* ignore parse errors */ } } } } return response; }; // Method 2: Intercept Response.prototype.json const originalJson = Response.prototype.json; Response.prototype.json = async function() { const data = await originalJson.apply(this); const apiType = detectResponseType(data) || activeAdapter.getApiType(this.url || ''); if (apiType) { handleApiResponse(apiType, normalizeResponseData(data)); } return data; }; // Method 3: Intercept JSON.parse (needed for wrapped fetch like Dynatrace) if (['Marriott', 'Hyatt', 'Hilton'].includes(activeAdapter.name)) { const originalJSONParse = JSON.parse; JSON.parse = function(text, reviver) { const data = originalJSONParse.call(this, text, reviver); const apiType = detectResponseType(data); if (apiType) { log('[JSON.parse]', activeAdapter.name, apiType); handleApiResponse(apiType, normalizeResponseData(data)); } return data; }; } // Method 4: Intercept XHR (fallback) const originalXHROpen = XMLHttpRequest.prototype.open; const originalXHRSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url, ...rest) { this._stayvalueUrl = url; return originalXHROpen.apply(this, [method, url, ...rest]); }; XMLHttpRequest.prototype.send = function(...args) { const self = this; const url = this._stayvalueUrl; if (url) { const isGraphQL = url.includes('graphql'); const urlApiType = activeAdapter.getApiType(url); if (isGraphQL || urlApiType) { this.addEventListener('load', function() { try { const data = JSON.parse(self.responseText); const apiType = detectResponseType(data) || urlApiType; if (apiType) { log('[XHR]', activeAdapter.name, apiType); handleApiResponse(apiType, normalizeResponseData(data)); } } catch (e) { /* ignore parse errors */ } }); } } return originalXHRSend.apply(this, args); }; log('Network interception set up for', activeAdapter.name); } function handleApiResponse(apiType, data) { log('Intercepted', apiType, 'API response'); switch (apiType) { case 'availability': const hotels = activeAdapter.parseAvailabilityResponse(data); const updatedHotels = []; hotels.forEach(hotel => { const existing = hotelCache.get(hotel.hotelCode); let dataChanged = false; // Smart merge: combine data from multiple API calls if (existing?.data) { const ex = existing.data; // Points: keep from whichever has them, prefer new if available if (!hotel.lowestPoints && ex.lowestPoints) { hotel.lowestPoints = ex.lowestPoints; } else if (hotel.lowestPoints && !ex.lowestPoints) { dataChanged = true; // New points data arrived } // Brand: keep non-default brand if (hotel.brandCode === 'default' && ex.brandCode !== 'default') { hotel.brandCode = ex.brandCode; } // Hotel name: keep if exists if (!hotel.hotelName && ex.hotelName) { hotel.hotelName = ex.hotelName; } // Cash rates: prefer the one with tax info if (hotel.lowestCash && ex.lowestCash) { const newHasTaxes = hotel.lowestCash.taxes > 0; const exHasTaxes = ex.lowestCash.taxes > 0; if (exHasTaxes && !newHasTaxes) { hotel.lowestCash = ex.lowestCash; } else if (newHasTaxes && !exHasTaxes) { dataChanged = true; // New tax data arrived } } else if (!hotel.lowestCash && ex.lowestCash) { hotel.lowestCash = ex.lowestCash; } else if (hotel.lowestCash && !ex.lowestCash) { dataChanged = true; // New cash data arrived } // Length of stay: keep if exists if (!hotel.lengthOfStay && ex.lengthOfStay) { hotel.lengthOfStay = ex.lengthOfStay; } // Check if data is meaningfully different if (hotel.lowestPoints !== ex.lowestPoints || hotel.lowestCash?.total !== ex.lowestCash?.total) { dataChanged = true; } } else { dataChanged = true; // New hotel } hotelCache.set(hotel.hotelCode, { data: hotel, timestamp: Date.now() }); if (dataChanged) { updatedHotels.push(hotel.hotelCode); } log('Cached:', hotel.hotelCode, '| Brand:', hotel.brandCode, '| Points:', hotel.lowestPoints || 'N/A', '| Cash:', hotel.lowestCash?.total || 'N/A', '| Taxes:', hotel.lowestCash?.taxes || 0, dataChanged ? '(updated)' : ''); }); // Clear processed markers for updated hotels so they get re-rendered if (updatedHotels.length > 0) { log('Refreshing display for', updatedHotels.length, 'hotels with new data'); document.querySelectorAll('[data-stayvalue-processed]').forEach(card => { const hotelCode = activeAdapter.getHotelCodeFromCard(card); if (hotelCode && updatedHotels.includes(hotelCode)) { card.removeAttribute('data-stayvalue-processed'); card.querySelector('.stayvalue-display')?.remove(); } }); } saveHotelCacheToStorage(); debouncedProcess(); break; case 'profile': const profile = activeAdapter.parseProfileResponse(data); userProfile = profile; saveProfileToStorage(); if (profile.levelDescription) { showInfo(`StayValue: ${profile.levelDescription} | ${profile.pointsBalance?.toLocaleString() || 0} pts`); } break; case 'currency': const rate = activeAdapter.parseCurrencyResponse(data); if (rate) { const key = `${rate.from}_${rate.to}`; currencyRates.set(key, rate.rate); log('Stored currency rate:', key, '=', rate.rate); saveCurrencyRatesToStorage(); debouncedProcess(); } break; } } // ============================================ // UTILITY FUNCTIONS // ============================================ function log(...args) { if (CONFIG.debug) { console.log('[StayValue]', ...args); } } function fmtDollars(amount) { if (amount === null || amount === undefined) return '?'; return amount.toFixed(GLOBAL_CONFIG.dollarDecimals); } function fmtPoints(points) { if (points === null || points === undefined) return '?'; return Math.round(points).toLocaleString(); } function fmtPercent(rate) { return (rate * 100).toFixed(1); } function formatCPP(cpp) { if (cpp === null) return ''; return cpp.toFixed(2) + ' cpp'; } // Fallback exchange rates (approximate, used if API fails) const FALLBACK_EXCHANGE_RATES = { 'JPY_USD': 0.0067, // ~150 JPY = 1 USD 'EUR_USD': 1.08, 'GBP_USD': 1.27, 'CAD_USD': 0.74, 'AUD_USD': 0.65, 'CNY_USD': 0.14, 'KRW_USD': 0.00075, // ~1333 KRW = 1 USD 'THB_USD': 0.029, 'SGD_USD': 0.74, 'HKD_USD': 0.13, 'MXN_USD': 0.058, 'INR_USD': 0.012 }; // Fetch exchange rates from free API function fetchExchangeRates() { // Check if we have recent rates (less than 6 hours old) const lastFetch = GM_getValue('exchangeRates_timestamp', 0); const SIX_HOURS = 6 * 60 * 60 * 1000; if (Date.now() - lastFetch < SIX_HOURS) { const storedRates = GM_getValue('exchangeRates', null); if (storedRates) { Object.entries(storedRates).forEach(([key, value]) => { currencyRates.set(key, value); }); log('Loaded', Object.keys(storedRates).length, 'exchange rates from cache'); return; } } log('Fetching exchange rates from API...'); GM_xmlhttpRequest({ method: 'GET', url: 'https://api.exchangerate-api.com/v4/latest/USD', onload: function(response) { try { const data = JSON.parse(response.responseText); if (data.rates) { const ratesToStore = {}; // Convert rates: API gives USD -> X, we need X -> USD Object.entries(data.rates).forEach(([currency, rate]) => { if (rate > 0) { const key = `${currency}_USD`; const inverseRate = 1 / rate; currencyRates.set(key, inverseRate); ratesToStore[key] = inverseRate; } }); // Store in GM storage for persistence GM_setValue('exchangeRates', ratesToStore); GM_setValue('exchangeRates_timestamp', Date.now()); log('Fetched', Object.keys(ratesToStore).length, 'exchange rates'); // Reprocess hotels with new rates debouncedProcess(); } } catch (e) { log('Error parsing exchange rate response:', e); } }, onerror: function(error) { log('Error fetching exchange rates:', error); } }); } function convertToUSD(amount, fromCurrency) { if (!amount || fromCurrency === 'USD') { return parseFloat(amount); } const key = `${fromCurrency}_USD`; let rate = currencyRates.get(key); if (!rate) { // Try fallback rates rate = FALLBACK_EXCHANGE_RATES[key]; if (rate) { log('Using fallback exchange rate for', key, '=', rate); } else { log('No exchange rate found for', key); return null; } } return parseFloat(amount) * rate; } // ============================================ // CORE CALCULATIONS // ============================================ function calculateCPP(netCashCost, pointsToRedeem, pointsEarned) { const netPoints = pointsToRedeem + pointsEarned; if (!netCashCost || !netPoints || netPoints <= 0) return null; return (netCashCost * 100) / netPoints; } function calculatePointsEffectiveCost(points) { if (!points || points <= 0 || !activeAdapter?.chainConfig) return null; return points * activeAdapter.chainConfig.pointValue / 100; } function calculateCashEffectiveCost(totalUSD, roomRateUSD, bonusPoints, brandCode) { const chainConfig = activeAdapter.chainConfig; const pointsPerDollar = activeAdapter.getPointsPerDollar(brandCode, userProfile.eliteLevel); const cashback = totalUSD * chainConfig.cashbackRate; const travelAgentRebate = roomRateUSD * chainConfig.travelAgentRebateRate; const basePointsEarned = roomRateUSD * pointsPerDollar; const totalPointsEarned = basePointsEarned + bonusPoints; const pointsValue = totalPointsEarned * chainConfig.pointValue / 100; const effectiveCost = totalUSD - cashback - travelAgentRebate - pointsValue; return { grossCost: totalUSD, roomRate: roomRateUSD, cashback, travelAgentRebate, basePointsEarned, bonusPoints, totalPointsEarned, pointsValue, effectiveCost }; } function findBestCashRate(hotelData, convertFn) { const candidates = []; // Lowest cash rate as baseline if (hotelData.lowestCash) { const cash = hotelData.lowestCash; const totalUSD = convertFn(cash.total); const roomRateUSD = convertFn(cash.roomRate); const feesUSD = convertFn(cash.fees) || 0; const taxesUSD = convertFn(cash.taxes) || 0; if (totalUSD !== null && roomRateUSD !== null) { const effectiveCalc = calculateCashEffectiveCost(totalUSD, roomRateUSD, 0, hotelData.brandCode); candidates.push({ rateCode: 'lowest', bonusPoints: 0, totalUSD, roomRateUSD, feesUSD, taxesUSD, effectiveCost: effectiveCalc.effectiveCost, effectiveCalc }); } } // Check rate plans for bonus points if (hotelData.ratePlans) { hotelData.ratePlans.forEach(plan => { const bonusPoints = activeAdapter.getBonusPoints(plan.code); const totalUSD = convertFn(plan.total); const roomRateUSD = convertFn(plan.roomRate); const feesUSD = convertFn(plan.fees) || 0; const taxesUSD = convertFn(plan.taxes) || 0; if (totalUSD !== null && roomRateUSD !== null) { const effectiveCalc = calculateCashEffectiveCost(totalUSD, roomRateUSD, bonusPoints, hotelData.brandCode); candidates.push({ rateCode: plan.code, bonusPoints, totalUSD, roomRateUSD, feesUSD, taxesUSD, effectiveCost: effectiveCalc.effectiveCost, effectiveCalc }); } }); } if (candidates.length === 0) return null; candidates.sort((a, b) => a.effectiveCost - b.effectiveCost); return candidates[0]; } function determineBestRate(cashEffective, pointsEffectiveCost) { if (cashEffective === null && pointsEffectiveCost === null) { return null; } if (pointsEffectiveCost === null) { return { bestRate: 'cash', cashCost: cashEffective, pointsCost: null, savings: null }; } if (cashEffective === null) { return { bestRate: 'points', cashCost: null, pointsCost: pointsEffectiveCost, savings: null }; } const savings = Math.abs(cashEffective - pointsEffectiveCost); if (pointsEffectiveCost < cashEffective) { return { bestRate: 'points', cashCost: cashEffective, pointsCost: pointsEffectiveCost, savings }; } else { return { bestRate: 'cash', cashCost: cashEffective, pointsCost: pointsEffectiveCost, savings }; } } // ============================================ // STYLING // ============================================ const STYLES = ` .stayvalue-cpp { font-size: 12px; color: #666; margin-left: 4px; font-weight: normal; } .stayvalue-cpp.good-value { color: #2e7d32; font-weight: 600; } .stayvalue-cpp.bad-value { color: #c62828; } .stayvalue-badge { display: inline-flex; align-items: center; background: #e8f5e9; color: #2e7d32; padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; margin-left: 6px; } .stayvalue-badge::before { content: "\\2713 "; } .stayvalue-best-rate { font-size: 11px; margin-top: 2px; color: #555; } .stayvalue-best-rate .best { font-weight: 600; color: #2e7d32; } .stayvalue-best-rate .alt { color: #888; } .stayvalue-best-rate .savings { color: #1565c0; font-size: 10px; } .stayvalue-info { position: fixed; bottom: 20px; right: 20px; background: #333; color: #fff; padding: 10px 15px; border-radius: 8px; font-size: 12px; z-index: 10000; box-shadow: 0 2px 10px rgba(0,0,0,0.3); } .stayvalue-info.hidden { display: none; } `; function injectStyles() { if (document.getElementById('stayvalue-styles')) return; const style = document.createElement('style'); style.id = 'stayvalue-styles'; style.textContent = STYLES; document.head.appendChild(style); log('Styles injected'); } // ============================================ // DOM INJECTION // ============================================ let infoBox = null; function showInfo(message) { if (!infoBox) { infoBox = document.createElement('div'); infoBox.className = 'stayvalue-info'; document.body.appendChild(infoBox); } infoBox.textContent = message; infoBox.classList.remove('hidden'); setTimeout(() => { if (infoBox) infoBox.classList.add('hidden'); }, 3000); } function createCPPElement(cpp, isGood) { const span = document.createElement('span'); span.className = 'stayvalue-cpp' + (isGood ? ' good-value' : ' bad-value'); span.textContent = '(' + formatCPP(cpp) + ')'; return span; } function createBadge() { const badge = document.createElement('span'); badge.className = 'stayvalue-badge'; badge.textContent = 'Better Value'; return badge; } // ============================================ // MAIN PROCESSING // ============================================ function processHotelCards() { if (!activeAdapter || !activeAdapter.selectors.hotelCard) { log('No adapter or hotel card selector available'); return; } log('Processing hotel cards...'); log('Hotel cache has', hotelCache.size, 'entries'); if (hotelCache.size > 0) { log('Cached hotel codes:', Array.from(hotelCache.keys()).slice(0, 10).join(', ')); } const cards = document.querySelectorAll(activeAdapter.selectors.hotelCard); log('Found', cards.length, 'hotel cards'); let processed = 0; let needsCurrencyRate = new Set(); cards.forEach(card => { if (card.hasAttribute('data-stayvalue-processed')) return; const hotelCode = activeAdapter.getHotelCodeFromCard(card); if (!hotelCode) { log('No hotel code found for card'); return; } const cached = hotelCache.get(hotelCode); if (!cached?.data) { log('No cached data for:', hotelCode); return; } const hotelData = cached.data; const currency = hotelData.currency; const convertFn = (amount) => { if (amount === null || amount === undefined) return null; if (currency === 'USD') return parseFloat(amount); return convertToUSD(amount, currency); }; if (currency !== 'USD' && !currencyRates.has(`${currency}_USD`)) { needsCurrencyRate.add(currency); log('Waiting for exchange rate:', currency, '-> USD for', hotelCode); return; } const bestCashRate = findBestCashRate(hotelData, convertFn); const points = hotelData.lowestPoints; const pointsEffectiveCost = points ? calculatePointsEffectiveCost(points) : null; // Skip if neither cash nor points rate available if (!bestCashRate && !points) { log('No rates available for:', hotelCode); return; } let bestRateInfo = null; let cpp = null; let isGood = false; let netPoints = null; if (bestCashRate) { // Have both cash and points - full comparison bestRateInfo = determineBestRate(bestCashRate.effectiveCost, pointsEffectiveCost); if (bestRateInfo) { bestRateInfo.bestCashRate = bestCashRate; } if (points) { const effCalc = bestCashRate.effectiveCalc; const netCashCost = effCalc.grossCost - effCalc.cashback - effCalc.travelAgentRebate; netPoints = points + effCalc.totalPointsEarned; cpp = calculateCPP(netCashCost, points, effCalc.totalPointsEarned); isGood = cpp !== null && cpp >= activeAdapter.chainConfig.pointValue; } } else if (points) { // Points only (sold out for cash) bestRateInfo = { bestRate: 'points', cashCost: null, pointsCost: pointsEffectiveCost, savings: null, pointsOnly: true }; log('Points-only hotel (sold out for cash):', hotelCode); } log('Hotel:', hotelCode, '| Brand:', hotelData.brandCode, '| CPP:', cpp?.toFixed(2) || 'N/A', '| Best:', bestRateInfo?.bestRate || 'N/A', '| Elite:', userProfile.eliteLevel); injectDisplay(card, hotelCode, cpp, { points, netPoints, cashEffectiveCalc: bestCashRate?.effectiveCalc || null, pointsEffectiveCost, bestRateInfo, bestCashRate, eliteLevel: userProfile.eliteLevel }, isGood); card.setAttribute('data-stayvalue-processed', 'true'); processed++; }); if (processed > 0) { showInfo(`StayValue: Processed ${processed} hotels`); } else if (needsCurrencyRate.size > 0) { showInfo(`StayValue: Waiting for exchange rate (${Array.from(needsCurrencyRate).join(', ')})`); } } function injectDisplay(card, hotelCode, cpp, data, isGood) { try { if (card.querySelector('.stayvalue-display')) return; const container = activeAdapter.findPriceContainer(card); if (!container) { log('Could not find container for:', hotelCode); return; } const wrapper = document.createElement('div'); wrapper.className = 'stayvalue-display'; wrapper.style.cssText = 'display: flex; flex-direction: column; margin-top: 4px; align-items: flex-end; text-align: right;'; // CPP row if (cpp !== null && data.cashEffectiveCalc) { const cppRow = document.createElement('div'); cppRow.style.cssText = 'display: flex; align-items: center; flex-wrap: wrap;'; const cppEl = createCPPElement(cpp, isGood); const calc = data.cashEffectiveCalc; const best = data.bestCashRate; const netCost = calc.grossCost - calc.cashback - calc.travelAgentRebate; let grossBreakdown = `$${fmtDollars(best.roomRateUSD)} room`; if (best.feesUSD > 0) grossBreakdown += ` + $${fmtDollars(best.feesUSD)} fees`; if (best.taxesUSD > 0) grossBreakdown += ` + $${fmtDollars(best.taxesUSD)} tax`; let tooltip = `Best cash rate: ${best.rateCode}`; if (calc.bonusPoints > 0) tooltip += ` (+${fmtPoints(calc.bonusPoints)} bonus)`; tooltip += `\n Gross: $${fmtDollars(calc.grossCost)} (${grossBreakdown})\n`; tooltip += ` Cashback (${fmtPercent(activeAdapter.chainConfig.cashbackRate)}%): -$${fmtDollars(calc.cashback)}\n`; if (activeAdapter.chainConfig.travelAgentRebateRate > 0) { tooltip += ` TA Rebate (${fmtPercent(activeAdapter.chainConfig.travelAgentRebateRate)}%): -$${fmtDollars(calc.travelAgentRebate)}\n`; } tooltip += ` Net cost: $${fmtDollars(netCost)}\n`; tooltip += ` Points earned: ${fmtPoints(calc.totalPointsEarned)}`; if (calc.bonusPoints > 0) tooltip += ` (${fmtPoints(calc.basePointsEarned)} base + ${fmtPoints(calc.bonusPoints)} bonus)`; tooltip += `\n\nPoints booking:\n`; tooltip += ` Points needed: ${fmtPoints(data.points)}\n`; tooltip += ` Foregone earnings: ${fmtPoints(calc.totalPointsEarned)}\n`; tooltip += ` Net points: ${fmtPoints(data.netPoints)}\n\n`; tooltip += `Elite: ${data.eliteLevel}`; cppEl.title = tooltip; cppRow.appendChild(cppEl); if (isGood) cppRow.appendChild(createBadge()); wrapper.appendChild(cppRow); } // Best rate row const bestRateInfo = data.bestRateInfo; const bestCashRate = data.bestCashRate; if (bestRateInfo) { const bestRow = document.createElement('div'); bestRow.className = 'stayvalue-best-rate'; const bonusPoints = bestCashRate?.bonusPoints || 0; const bonusLabel = bonusPoints > 0 ? ` (+${Math.round(bonusPoints / 1000)}k)` : ''; const cashCostStr = bestRateInfo.cashCost !== null ? `$${fmtDollars(bestRateInfo.cashCost)}` : 'N/A'; const pointsCostStr = bestRateInfo.pointsCost !== null ? `$${fmtDollars(bestRateInfo.pointsCost)}` : 'N/A'; if (bestRateInfo.pointsOnly) { // Points-only hotel (sold out for cash) bestRow.innerHTML = `Points ${pointsCostStr} (cash sold out)`; } else if (bestRateInfo.bestRate === 'points' && bestRateInfo.pointsCost !== null) { bestRow.innerHTML = `Points ${pointsCostStr} vs Cash${bonusLabel} ${cashCostStr}`; if (bestRateInfo.savings > 0) bestRow.innerHTML += ` (save $${fmtDollars(bestRateInfo.savings)})`; } else if (bestRateInfo.bestRate === 'cash') { if (bestRateInfo.pointsCost !== null) { bestRow.innerHTML = `Cash${bonusLabel} ${cashCostStr} vs Points ${pointsCostStr}`; if (bestRateInfo.savings > 0) bestRow.innerHTML += ` (save $${fmtDollars(bestRateInfo.savings)})`; } else { bestRow.innerHTML = `Cash${bonusLabel} ${cashCostStr}`; } } let tooltip = ''; const effCalc = data.cashEffectiveCalc; if (bestRateInfo.pointsOnly) { // Points-only tooltip tooltip = `Points-only (cash sold out)\n\n`; tooltip += `Points redemption:\n`; tooltip += ` Points to redeem: ${fmtPoints(data.points)}\n`; tooltip += ` Point value (${activeAdapter.chainConfig.pointValue}¢/pt): $${fmtDollars(bestRateInfo.pointsCost)}\n`; } else if (effCalc) { let grossBreakdown = `$${fmtDollars(bestCashRate?.roomRateUSD)} room`; if (bestCashRate?.feesUSD > 0) grossBreakdown += ` + $${fmtDollars(bestCashRate.feesUSD)} fees`; if (bestCashRate?.taxesUSD > 0) grossBreakdown += ` + $${fmtDollars(bestCashRate.taxesUSD)} tax`; tooltip = `Effective cost comparison:\n\nBest cash rate: ${bestCashRate?.rateCode || 'lowest'}`; if (bonusPoints > 0) tooltip += ` (+${fmtPoints(bonusPoints)} bonus pts)`; tooltip += `\n Gross: $${fmtDollars(effCalc.grossCost)} (${grossBreakdown})\n`; tooltip += ` Cashback (${fmtPercent(activeAdapter.chainConfig.cashbackRate)}%): -$${fmtDollars(effCalc.cashback)}\n`; if (activeAdapter.chainConfig.travelAgentRebateRate > 0) { tooltip += ` TA Rebate (${fmtPercent(activeAdapter.chainConfig.travelAgentRebateRate)}%): -$${fmtDollars(effCalc.travelAgentRebate)}\n`; } tooltip += ` Base points: ${fmtPoints(effCalc.basePointsEarned)}\n`; if (bonusPoints > 0) tooltip += ` Bonus points: +${fmtPoints(bonusPoints)}\n`; tooltip += ` Total points: ${fmtPoints(effCalc.totalPointsEarned)}\n`; tooltip += ` Points value (${activeAdapter.chainConfig.pointValue}¢/pt): -$${fmtDollars(effCalc.pointsValue)}\n`; tooltip += ` Effective cost: $${fmtDollars(effCalc.effectiveCost)}\n\n`; if (bestRateInfo.pointsCost !== null && data.points) { tooltip += `Points redemption:\n`; tooltip += ` Points to redeem: ${fmtPoints(data.points)}\n`; tooltip += ` Point value (${activeAdapter.chainConfig.pointValue}¢/pt): $${fmtDollars(bestRateInfo.pointsCost)}\n`; } } bestRow.title = tooltip; wrapper.appendChild(bestRow); } container.parentNode.insertBefore(wrapper, container.nextSibling); log('Injected display for:', hotelCode); } catch (e) { log('Error injecting display for', hotelCode, ':', e.message); } } // ============================================ // IATA CODE INJECTION // ============================================ function fillIataInput() { if (!GLOBAL_CONFIG.iataCode || !activeAdapter) return; const selector = activeAdapter.selectors.iataInput; if (!selector) return; const iataInput = document.querySelector(selector); if (iataInput && iataInput.value !== GLOBAL_CONFIG.iataCode) { iataInput.value = GLOBAL_CONFIG.iataCode; iataInput.dispatchEvent(new Event('input', { bubbles: true })); iataInput.dispatchEvent(new Event('change', { bubbles: true })); log('Filled IATA input with:', GLOBAL_CONFIG.iataCode); } } function setupIataObserver() { if (!GLOBAL_CONFIG.iataCode || !activeAdapter?.selectors.iataInput) return; const observer = new MutationObserver(() => fillIataInput()); observer.observe(document.body, { childList: true, subtree: true }); fillIataInput(); } // ============================================ // DEBOUNCE // ============================================ function debounce(func, wait) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; } const debouncedProcess = debounce(processHotelCards, 300); // ============================================ // INITIALIZATION // ============================================ // Early init - runs at document-start before page scripts function initEarly() { if (!activeAdapter) { log('No adapter found for this site'); return; } log(`StayValue v2.7.0 early init for ${activeAdapter.name}...`); // Set up network interception ASAP to catch early requests loadFromStorage(); setupNetworkInterception(); // Wait for DOM to be ready for everything else if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initDOM); } else { initDOM(); } } // DOM init - runs when DOM is ready function initDOM() { log('Point valuation:', activeAdapter.chainConfig.pointValue, 'cpp'); log('Cashback rate:', (activeAdapter.chainConfig.cashbackRate * 100) + '%'); log('TA rebate rate:', (activeAdapter.chainConfig.travelAgentRebateRate * 100) + '%'); log('IATA code:', GLOBAL_CONFIG.iataCode || 'not set'); log('Default elite status:', activeAdapter.defaultEliteStatus); setupMenuCommands(); injectStyles(); fetchExchangeRates(); setupIataObserver(); // For Hyatt: try to extract data from embedded scripts if (activeAdapter.name === 'Hyatt' && activeAdapter.extractDataFromScripts) { setTimeout(() => activeAdapter.extractDataFromScripts(handleApiResponse, hotelCache, debouncedProcess), 500); } setTimeout(processHotelCards, 1500); // DOM observer for hotel cards const observer = new MutationObserver((mutations) => { if (!activeAdapter.selectors.hotelCard) return; const hasRelevantChanges = mutations.some(mutation => { return Array.from(mutation.addedNodes).some(node => { if (node.nodeType !== Node.ELEMENT_NODE) return false; if (node.classList?.contains('stayvalue-display') || node.classList?.contains('stayvalue-info')) return false; if (node.matches?.(activeAdapter.selectors.hotelCard)) return true; if (node.querySelector?.(activeAdapter.selectors.hotelCard)) return true; return false; }); }); if (hasRelevantChanges) { log('Relevant DOM changes detected'); debouncedProcess(); } }); observer.observe(document.body, { childList: true, subtree: true }); // URL change detection using History API let lastUrl = location.href; function handleUrlChange() { if (location.href === lastUrl) return; lastUrl = location.href; log('URL changed to:', lastUrl); // Clear processed markers and injected displays document.querySelectorAll('[data-stayvalue-processed]').forEach(el => el.removeAttribute('data-stayvalue-processed')); document.querySelectorAll('.stayvalue-display').forEach(el => el.remove()); // For Hilton: clear hotel cache on URL change to force fresh data if (activeAdapter.name === 'Hilton') { log('Clearing hotel cache for Hilton SPA navigation'); hotelCache.clear(); sessionStorage.removeItem('stayvalue_hotels'); } setTimeout(processHotelCards, 1500); } // Listen for history changes (SPA navigation) window.addEventListener('popstate', handleUrlChange); // Intercept pushState/replaceState for SPA frameworks const originalPushState = history.pushState; const originalReplaceState = history.replaceState; history.pushState = function(...args) { originalPushState.apply(this, args); handleUrlChange(); }; history.replaceState = function(...args) { originalReplaceState.apply(this, args); handleUrlChange(); }; log('StayValue initialized for', activeAdapter.name); showInfo(`StayValue active (${activeAdapter.name})`); } // Start early init immediately initEarly(); })();