// ==UserScript==
// @name Haiyaa Points!
// @namespace http://tampermonkey.net/
// @version 3.4
// @description Streamline your Hyatt points booking experience, Haiyaa!
// @author World of Haiyaa
// @match https://www.hyatt.com/explore-hotels/rate-calendar*
// @match https://www.hyatt.com/*/explore-hotels/rate-calendar*
// @match https://www.hyatt.com/explore-hotels/map*
// @match https://www.hyatt.com/*/explore-hotels/map*
// @match https://www.hyatt.com/search/hotels/*
// @grant GM_xmlhttpRequest
// @connect www.hyatt.com
// ==/UserScript==
(function() {
'use strict';
const priveHotelCodes = new Set(["menph", "suzph", "tusob", "abdcc", "jedph", "istph", "dohph", "selaz", "sanrs", "cgkub", "saogh", "flrub", "apcal", "riogh", "seagh", "selrs", "lisaz", "shagh", "sinrs", "taigh", "tparw", "tyogh", "caict", "paraz", "phlph", "ausra", "bnaub", "bcnub", "msyrf", "balgh", "nasgh", "jcagh", "dohgh", "goagh", "kauai", "musca", "mexhr", "aruba", "chesa", "naprn", "danhr", "guamh", "sanhc", "huahi", "newpo", "hunrh", "champ", "tvllt", "auslp", "oggrm", "ncehr", "phuhr", "scott", "nbsph", "tamay", "thess", "hnlrw", "nycam", "amsaz", "delaz", "longe", "oggaw", "apcrn", "yowaz", "liraz", "sanas", "savrd", "phxaz", "shaaz", "tyoaz", "laxss", "abuph", "sanpa", "bkkph", "beave", "beiph", "bueph", "istct", "mvdhy", "busph", "canbe", "cheph", "chiph", "dxbph", "guaph", "hydph", "mldph", "zuhal", "melph", "milph", "limct", "sclct", "kulph", "nycph", "ninph", "parph", "saiph", "sanph", "selph", "shaph", "repph", "sydph", "tyoph", "romrt", "vieph", "wasph", "madct", "guact", "znzph", "zurph", "goial", "jaial", "cabph", "mxpct", "lgapr", "tyoct", "phlct", "madrp", "aushd", "phxub", "msyum", "skbph", "sinaz", "ammgh", "atlgh", "beigh", "hanph", "cgkph", "bergh", "dxbgh", "bangh", "nycuc", "secim", "dpsbl", "msyub", "colog", "zocur", "zvrim", "cania", "shang", "hkggh", "vcect", "westl", "adbri", "kuagh", "macgh", "melbo", "nayrw", "auhgh", "ptyrp", "satgh", "delrh", "calrc", "chenn", "pierc", "mlact", "dxbhc", "dusse", "hakhr", "kwest", "seplc", "honhr", "drrpc", "kievh", "kyoto", "lonch", "drcfu", "mainz", "bkkhr", "boggh", "cokgh", "coral", "ctgrc", "darhr", "denrd", "guagh", "mucaz", "chiub", "lgatg", "okaro", "satrs", "savrs", "sfors", "vieaz", "madel", "lgajd", "sjcjc", "nycts", "isthr", "szxph", "lgath", "cslth", "bnath", "seath", "zihth", "budub", "chith", "sfojd", "kulal", "mctal", "dpsak", "dpsas", "socal", "dpsau", "dpsav", "hghaw", "sjcal", "itmph", "aklph", "lhrub", "ausob", "egegh", "dxbct", "madrm", "bcnrb", "agart", "sbars", "ctsph", "ctugh", "amdhr", "rdudc", "btvdh", "sepbc", "sofrs", "setpc", "iadth", "pekal", "aqjra", "bnarn", "tyoty", "ruhhr", "secbr", "bosto", "sanen", "szxaz", "xmnaz", "oggal", "biqub", "mcijd", "miact", "dusxd", "addra", "searl", "mrydm", "albob", "rnodh", "dfwth", "satth", "dendv", "sllal", "dpsaz", "austh", "bnerb", "ctuub", "mlarm", "pekub", "pnhrp", "savth", "torph", "zrhrz", "chsdb", "sando", "sanjo", "ibzdh", "laxuf", "usmrk", "dmmgh", "denth", "bnact", "oakub", "laxdi", "canif", "lgatp", "atlct", "melct", "prgaz", "romjd", "arnub", "csxgh", "shegh", "chsdv", "drmpc", "zoehm", "zocdm", "searm", "cunia", "pvrif", "sjdif", "smfct", "jdzub", "dxbcl", "dubct", "ptyub", "jtrub", "sardh", "agpub", "lishr", "mldal", "zoapc", "sepdc", "seccc", "secpm", "sebmi", "drepm", "drbmi", "fswub", "marph", "salct", "xiygh", "utpaz", "jairj", "mexaz", "fukgh", "semrc", "sfous", "bodjd", "edidr", "sford", "iadct", "torjd", "nkgaz", "davub", "pspaz", "pdxct", "seiim", "lhrph", "csxph", "shaal", "fraum", "dohaz", "slcgp", "slcgr", "kmggh", "grggh", "miaob", "johpj", "yulct", "yyzjd", "tivrk", "ausct", "rmugh", "gdlrg", "iahth", "kwigh", "swfuc", "bnadz", "bjxub", "dpaal", "hourw", "sydrs", "slcpc", "bkict", "kmqct", "laxdz", "pmijc", "mangh", "okcub", "tyoub", "lonrb", "bkksb", "huash", "ibzsi", "lgase", "lgash", "lonsl", "melsx", "mldsm", "yvrph", "sinss", "ptyuv", "sepcr", "sjdkp", "zadrz", "satjf", "iahkb", "auskd"]);
const futureOpeningHotels = {"tyocp": "2025-10-07", "sydcp": "2025-10-13", "uc037": "2025-10-13", "cokrm": "2025-10-15", "torza": "2025-10-29", "cabph": "2025-10-30", "shath": "2025-10-30", "hyvpc": "2025-11-01", "uc155": "2025-11-03", "pdszp": "2025-11-04", "mklzj": "2025-11-12", "cunza": "2025-11-13", "houkh": "2025-11-18", "geozd": "2025-11-19", "hsvqh": "2025-11-19", "midzm": "2025-11-20", "ywgct": "2025-11-25", "bp024": "2025-12-01", "sjuct": "2025-12-02", "yqtxt": "2025-12-03", "qroct": "2025-12-09", "tyoph": "2025-12-09", "romrt": "2025-12-10", "bznza": "2025-12-11", "gegzp": "2025-12-17", "cprzc": "2025-12-18", "lgajl": "2025-12-18", "rsigh": "2026-01-20", "gcmgh": "2026-02-03", "fraxg": "2026-02-06", "romth": "2026-02-06", "cxrrn": "2026-02-10", "fchxn": "2026-02-11", "albxy": "2026-02-25", "waczw": "2026-02-26", "lisaz": "2026-03-04", "sjozc": "2026-03-11", "bkkaz": "2026-03-15", "chacp": "2026-03-19", "rsiob": "2026-03-30", "cangh": "2026-04-01", "rjkdh": "2026-04-01", "choqh": "2026-04-08", "mexph": "2026-05-12", "iagub": "2026-05-14", "plsaz": "2026-05-14", "lonry": "2026-05-26", "ecpxs": "2026-05-27", "lrdql": "2026-06-10", "jaxst": "2026-08-19", "lhrzp": "2026-09-01", "uc089": "2026-10-01"};
function addGlobalStyle(css) {
const head = document.head || document.getElementsByTagName('head')[0];
if (!head) { return; }
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
head.appendChild(style);
}
addGlobalStyle(`
.haiyaa-trigger-btn { background-color: #0D2D52; color: white; border: none; border-radius: 6px; padding: 6px 14px; font-size: 14px; font-weight: bold; cursor: pointer; margin: 0 10px; transition: all 0.2s; }
.haiyaa-trigger-btn:hover { background-color: #1a4a87; }
.haiyaa-trigger-btn:disabled { background-color: #888; cursor: not-allowed; }
.visualizer-overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background-color: rgba(0, 0, 0, 0.6); z-index: 9999; display: flex; justify-content: center; align-items: center; }
.visualizer-modal { background-color: #ffffff; padding: 25px; border-radius: 12px; box-shadow: 0 5px 20px rgba(0,0,0,0.3); width: auto; max-width: 95vw; max-height: 90vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #e0e0e0; }
.modal-header h3 { margin: 0; font-size: 18px; white-space: nowrap; }
.modal-header-right { display: flex; align-items: center; gap: 15px; }
.modal-header-right select { padding: 5px; border-radius: 5px; border: 1px solid #ccc; font-size: 11px; }
.prive-link-btn { font-size: 12px; color: #0D2D52; text-decoration: none; font-weight: bold; border: 1px solid #0D2D52; padding: 5px 10px; border-radius: 5px; transition: all 0.2s; white-space: nowrap; }
.prive-link-btn:hover { background-color: #0D2D52; color: white; }
.modal-close-btn { font-size: 24px; font-weight: bold; color: #888; cursor: pointer; border: none; background: none; padding: 0; }
.modal-close-btn:hover { color: #000; }
.generic-points-legend { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 15px; font-size: 12px; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
.multi-chart-container { transition: opacity 0.2s; }
.multi-chart-container h4 { display: flex; justify-content: center; align-items: center; gap: 10px; font-size: 15px; font-weight: 600; margin: 20px 0 8px; text-align: center; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; border-top: 1px solid #eee; padding-top: 20px; }
.multi-chart-container h4:first-child { border-top: none; margin-top: 0; padding-top: 0; }
.hotel-legend { display: flex; justify-content: center; align-items: center; gap: 15px; font-size: 11px; margin-bottom: 10px; }
.calendar-chart-container { display: grid; grid-template-areas: "empty months" "weeks grid"; grid-template-columns: auto 1fr; grid-template-rows: auto 1fr; gap: 5px 3px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
.points-calendar-grid { transition: opacity 0.2s; }
.calendar-months { grid-area: months; position: relative; height: 15px; }
.calendar-months .month { position: absolute; top: 0; font-size: 11px; color: #555; }
.calendar-weeks { grid-area: weeks; font-size: 11px; color: #555; }
.points-calendar-grid { grid-area: grid; }
.calendar-weeks .week-day { height: 14px; margin-bottom: 3px; display: flex; align-items: center; }
.points-calendar-grid { display: grid; grid-template-columns: repeat(53, 14px); grid-template-rows: repeat(7, 14px); grid-auto-flow: column; grid-gap: 3px; }
.calendar-day { width: 14px; height: 14px; background-color: #ebedf0; border: 1px solid #e1e4e8; border-radius: 3px; transition: all 0.1s; position: relative; }
.calendar-day.is-holiday { border-width: 2px; box-sizing: border-box; }
.day-number { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-size: 9px; font-weight: bold; color: #333; text-shadow: none; }
.calendar-day.off-peak .day-number, .calendar-day.standard .day-number, .calendar-day.peak .day-number { color: white; text-shadow: 0 0 2px rgba(0,0,0,0.7); }
.calendar-day.clickable:hover { transform: scale(1.2); box-shadow: 0 0 5px rgba(0,0,0,0.5); cursor: pointer; }
.calendar-day.off-peak { background-color: #216e39; border-color: transparent; }
.calendar-day.standard { background-color: #9be9a8; border-color: transparent; }
.calendar-day.peak { background-color: #ff9800; border-color: transparent; }
.price-tier-1 { background-color: #1a9641; border-color: transparent; } .price-tier-2 { background-color: #a6d96a; border-color: transparent; } .price-tier-3 { background-color: #ffffbf; border-color: transparent; } .price-tier-4 { background-color: #fdae61; border-color: transparent; } .price-tier-5 { background-color: #d7191c; border-color: transparent; }
.price-tier-1 .day-number, .price-tier-2 .day-number, .price-tier-3 .day-number, .price-tier-4 .day-number, .price-tier-5 .day-number { color: white; text-shadow: 0 0 2px rgba(0,0,0,0.7); }
.price-tier-3 .day-number { color: #333; text-shadow: none; }
.calendar-legend, .holiday-legend { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; font-size: 12px; margin-top: 15px; color: #555; }
.legend-item { display: flex; align-items: center; margin: 2px 8px; }
.legend-color-box { width: 14px; height: 14px; border-radius: 3px; margin-right: 5px; }
.holiday-legend { margin-top: 5px; }
.holiday-legend .legend-color-box { border-width: 2px; border-style: solid; background-color: transparent; }
.availability-disclaimer { font-size: 10px; font-style: italic; color: #888; text-align: center; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee; display: flex; justify-content: center; align-items: center; gap: 5px; }
.availability-disclaimer select { padding: 5px; border-radius: 5px; border: 1px solid #ccc; font-size: 11px; }
.hidden { display: none !important; }
.comparison-toggle-container { display: flex; align-items: center; gap: 8px; font-size: 12px; }
.switch { position: relative; display: inline-block; width: 34px; height: 20px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; border-radius: 20px; }
.slider:before { position: absolute; content: ""; height: 12px; width: 12px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: #0D2D52; }
input:checked + .slider:before { transform: translateX(14px); }
#hotelSelectionModal ul { list-style-type: none; padding: 0; margin: 20px 0; max-height: 50vh; overflow-y: auto; border-top: 1px solid #eee; border-bottom: 1px solid #eee; }
#hotelSelectionModal li { padding: 8px 4px; border-bottom: 1px solid #eee; }
#hotelSelectionModal li:last-child { border-bottom: none; }
#hotelSelectionModal label { margin-left: 8px; font-size: 14px; cursor: pointer; }
.modal-footer { margin-top: 20px; display: flex; justify-content: space-between; align-items: center; }
.selection-counter { font-size: 12px; color: #666; font-style: italic; }
.compare-btn { background-color: #0D2D52; color: white; border: none; border-radius: 6px; padding: 8px 16px; font-size: 14px; font-weight: bold; cursor: pointer; }
.compare-btn:disabled { background-color: #888; cursor: not-allowed; }
.view-total-toggle-container { display: flex; justify-content: center; align-items: center; gap: 8px; font-size: 12px; margin: 10px 0; }
`);
const holidays = new Map([...getHolidays(new Date().getFullYear()), ...getHolidays(new Date().getFullYear() + 1)]);
function getHotelCodeFromURL() {
return new URLSearchParams(window.location.search).get('spiritCode');
}
async function fetchAllData(hotelCode, los = 1) {
const today = new Date();
const endDate = new Date();
endDate.setDate(today.getDate() + 365);
let startDateStr = toLocalDateString(today);
const openingDateStr = futureOpeningHotels[hotelCode];
if (openingDateStr && new Date(openingDateStr) > today) {
startDateStr = openingDateStr;
}
const endDateStr = toLocalDateString(endDate);
const apiUrl = `https://www.hyatt.com/explore-hotels/service/avail/days?spiritCode=${hotelCode}&startDate=${startDateStr}&endDate=${endDateStr}&numAdults=1&numChildren=0&roomQuantity=1&los=${los}&isMock=false`;
return new Promise(resolve => {
GM_xmlhttpRequest({
method: "GET", url: apiUrl, headers: { "Accept": "application/json" },
onload: response => {
const allData = { STANDARD_ROOM: new Map(), CLUB: new Map(), STANDARD_SUITE: new Map(), PREMIUM_SUITE: new Map() };
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
const days = data.days || {};
for (const date in days) {
const roomsOnDate = days[date];
if (roomsOnDate) {
Object.keys(allData).forEach(roomType => {
if (roomsOnDate[roomType]) {
const roomData = roomsOnDate[roomType];
const level = roomData.pointslevel || roomData.pointsLevel;
const type = level ? level.toLowerCase() : 'unknown';
const pointsArray = Array.isArray(roomData.pointsValue) ? roomData.pointsValue : (roomData.pointsValue ? [roomData.pointsValue] : []);
const points = pointsArray.length > 0 ? pointsArray[0] : null;
const totalPoints = pointsArray.reduce((sum, p) => sum + p, 0);
if (points) {
allData[roomType].set(date, { type, points, pointsArray, totalPoints });
}
}
});
}
}
} catch (e) {
console.error("Failed to parse JSON for hotel:", hotelCode, e);
}
}
resolve({ hotelCode, allData });
},
onerror: () => resolve({ hotelCode, allData: { STANDARD_ROOM: new Map(), CLUB: new Map(), STANDARD_SUITE: new Map(), PREMIUM_SUITE: new Map() }})
});
});
}
function getPointsTiers(priceMap) {
const tiers = {};
for (const item of priceMap.values()) {
if (item.type && item.points && !tiers[item.type]) {
tiers[item.type] = item.points;
}
if (tiers.off_peak && tiers.standard && tiers.peak) break;
}
return tiers;
}
function toLocalDateString(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function getHolidays(year) {
const holidays = new Map();
const nthWeekdayOfMonth = (n, weekday, month, year) => { let count = 0; const date = new Date(year, month, 1); while (count < n) { if (date.getDay() === weekday) count++; if (count < n) date.setDate(date.getDate() + 1); } return date; };
const lastWeekdayOfMonth = (weekday, month, year) => { const date = new Date(year, month + 1, 0); while (date.getDay() !== weekday) { date.setDate(date.getDate() - 1); } return date; };
const formatDate = (date) => toLocalDateString(date);
holidays.set(formatDate(new Date(year, 0, 1)), { name: "New Year's Day", color: '#FFD700' });
holidays.set(formatDate(nthWeekdayOfMonth(3, 1, 0, year)), { name: 'MLK Day', color: '#800080' });
holidays.set(formatDate(lastWeekdayOfMonth(1, 4, year)), { name: 'Memorial Day', color: '#1E90FF' });
holidays.set(formatDate(new Date(year, 6, 4)), { name: 'Independence Day', color: '#FF0000' });
holidays.set(formatDate(nthWeekdayOfMonth(1, 1, 8, year)), { name: 'Labor Day', color: '#008080' });
holidays.set(formatDate(nthWeekdayOfMonth(4, 4, 10, year)), { name: 'Thanksgiving', color: '#A0522D' });
holidays.set(formatDate(new Date(year, 11, 25)), { name: 'Christmas Day', color: '#228B22' });
return holidays;
}
function generateCalendarGrid(gridContainer, monthsContainer, weeksContainer, priceMap, hotelCode, losSelector, displayMode = 'default') {
gridContainer.innerHTML = '';
monthsContainer.innerHTML = '';
weeksContainer.innerHTML = '';
const weekDays = ['Sun', '', 'Tue', '', 'Thu', '', 'Sat'];
weekDays.forEach(day => { weeksContainer.innerHTML += `<div class="week-day">${day}</div>`; });
if (!priceMap) return;
let monthsHTML = '', lastMonth = -1, lastMonthLabelWeekIndex = -10;
const today = new Date();
const startDate = new Date(today);
startDate.setDate(startDate.getDate() - startDate.getDay());
const dayCount = 53 * 7;
for (let i = 0; i < dayCount; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dayElement = document.createElement('div');
dayElement.className = 'calendar-day';
const weekIndex = Math.floor(i / 7);
const currentMonth = currentDate.getMonth();
if (currentDate.getDate() === 1 && currentMonth !== lastMonth) {
if (weekIndex > lastMonthLabelWeekIndex + 2) {
const monthName = currentDate.toLocaleString('default', { month: 'short' });
const leftPosition = weekIndex * 17;
monthsHTML += `<div class="month" style="left: ${leftPosition}px;">${monthName}</div>`;
lastMonth = currentMonth;
lastMonthLabelWeekIndex = weekIndex;
}
}
const dateString = toLocalDateString(currentDate);
if (currentDate >= today) {
const dayData = priceMap.get(dateString);
let title = dateString;
const holiday = holidays.get(dateString);
if (currentDate.getDate() === 1) {
dayElement.innerHTML = `<div class="day-number">1</div>`;
}
if (dayData) {
dayElement.classList.add(dayData.type.replace('_', '-'));
if (displayMode === 'total' && dayData.pointsArray && dayData.pointsArray.length > 1) {
title = `Check in ${dateString}`;
title += `\nTotal ${dayData.totalPoints.toLocaleString()} points`;
title += `\n(${dayData.pointsArray.map(p => p.toLocaleString()).join(' + ')})`;
} else {
title += `\n${dayData.points.toLocaleString()} Points (${dayData.type})`;
}
if (holiday) {
dayElement.classList.add('is-holiday');
dayElement.style.borderColor = holiday.color;
title += `\n${holiday.name}`;
}
dayElement.classList.add('clickable');
title += `\nClick to book!`;
dayElement.addEventListener('click', () => {
const checkoutDate = new Date(currentDate);
checkoutDate.setDate(checkoutDate.getDate() + parseInt(losSelector.value, 10));
const checkoutDateString = toLocalDateString(checkoutDate);
const bookingUrl = `https://www.hyatt.com/shop/rooms/${hotelCode}?checkinDate=${dateString}&checkoutDate=${checkoutDateString}&rooms=1&adults=1&kids=0&rateFilter=woh`;
window.open(bookingUrl, '_blank');
});
} else {
if (holiday) {
dayElement.classList.add('is-holiday');
dayElement.style.borderColor = holiday.color;
title += `\n${holiday.name}`;
}
title += `\nNot Available`;
}
dayElement.title = title;
}
gridContainer.appendChild(dayElement);
}
monthsContainer.innerHTML = monthsHTML;
}
function getCategoryFromDOM(container = document) {
const categoryEl = container.querySelector('[data-locator^="hotel-award-category_"]');
if (categoryEl) {
const locator = categoryEl.getAttribute('data-locator');
const catValue = locator.split('_')[1];
if (catValue) {
return `[Cat ${catValue}]`;
}
}
return '';
}
function createSingleVisualizerUI(initialResult) {
const hotelCode = getHotelCodeFromURL();
let currentAllData = initialResult.allData;
let isTotalView = false;
const overlay = document.createElement('div');
overlay.className = 'visualizer-overlay hidden';
const modal = document.createElement('div');
modal.className = 'visualizer-modal';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
const hotelNameEl = document.querySelector('[data-locator="hotel-name-long"]');
const categoryString = getCategoryFromDOM();
const hotelName = hotelNameEl ? hotelNameEl.textContent.trim() : hotelCode;
title.textContent = `${hotelName} ${categoryString}`;
header.appendChild(title);
const headerRight = document.createElement('div');
headerRight.className = 'modal-header-right';
if (priveHotelCodes.has(hotelCode)) {
const priveLink = document.createElement('a');
priveLink.href = 'https://www.hotelft.com/preferred-program/hyatt-prive';
priveLink.textContent = 'Enjoy Privé benefits';
priveLink.target = '_blank';
priveLink.className = 'prive-link-btn';
headerRight.appendChild(priveLink);
}
const roomSelector = document.createElement('select');
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close-btn';
closeBtn.innerHTML = `×`;
headerRight.append(roomSelector, closeBtn);
header.appendChild(headerRight);
const toggleContainer = document.createElement('div');
toggleContainer.className = 'view-total-toggle-container hidden';
toggleContainer.innerHTML = `
<label class="switch">
<input type="checkbox" id="totalViewToggle">
<span class="slider"></span>
</label>
<span>View total for stay</span>
`;
const totalViewToggle = toggleContainer.querySelector('#totalViewToggle');
const chartContainer = document.createElement('div');
chartContainer.className = 'calendar-chart-container';
const monthsContainer = document.createElement('div');
monthsContainer.className = 'calendar-months';
const weeksContainer = document.createElement('div');
weeksContainer.className = 'calendar-weeks';
const grid = document.createElement('div');
grid.className = 'points-calendar-grid';
chartContainer.append(monthsContainer, weeksContainer, grid);
const legend = document.createElement('div');
legend.className = 'calendar-legend';
const holidayLegend = document.createElement('div');
holidayLegend.className = 'holiday-legend';
const disclaimer = document.createElement('div');
disclaimer.className = 'availability-disclaimer';
const losSelector = document.createElement('select');
for (let i = 1; i <= 10; i++) {
losSelector.options.add(new Option(i, i));
}
disclaimer.append('Availability based on ', losSelector, ' night(s) stay.');
modal.append(header, toggleContainer, chartContainer, legend, holidayLegend, disclaimer);
overlay.appendChild(modal);
let holidayLegendHTML = '';
const chronologicalHolidays = Array.from(holidays.values());
const addedHolidays = new Set();
for(const holiday of chronologicalHolidays){
if(!addedHolidays.has(holiday.name)){
holidayLegendHTML += `<div class="legend-item"><div class="legend-color-box" style="border-color: ${holiday.color};"></div> ${holiday.name}</div>`;
addedHolidays.add(holiday.name);
}
}
holidayLegend.innerHTML = holidayLegendHTML;
function updateRoomSelector(data) {
const currentSelection = roomSelector.value;
let optionsHTML = '';
if (data.STANDARD_ROOM?.size > 0) optionsHTML += `<option value="STANDARD_ROOM">Standard Room</option>`;
if (data.CLUB?.size > 0) optionsHTML += `<option value="CLUB">Club Access</option>`;
if (data.STANDARD_SUITE?.size > 0) optionsHTML += `<option value="STANDARD_SUITE">Standard Suite</option>`;
if (data.PREMIUM_SUITE?.size > 0) optionsHTML += `<option value="PREMIUM_SUITE">Premium Suite</option>`;
roomSelector.innerHTML = optionsHTML;
if (Array.from(roomSelector.options).some(opt => opt.value === currentSelection)) {
roomSelector.value = currentSelection;
}
}
function render(roomType) {
const priceMap = currentAllData[roomType];
const displayMode = (isTotalView && parseInt(losSelector.value, 10) > 1) ? 'total' : 'default';
generateCalendarGrid(grid, monthsContainer, weeksContainer, priceMap, hotelCode, losSelector, displayMode);
grid.querySelectorAll('.calendar-day[class*="price-tier-"]').forEach(el => el.className = el.className.replace(/price-tier-\d/g, '').trim());
if (displayMode === 'total') {
const allTotalPoints = Array.from(priceMap.values()).map(d => d.totalPoints);
const minPoints = Math.min(...allTotalPoints);
const maxPoints = Math.max(...allTotalPoints);
const range = maxPoints - minPoints;
const tierCount = 5;
const tierSize = range > 0 ? range / tierCount : 0;
const buckets = Array.from({length: tierCount}, (_, i) => minPoints + ((i + 1) * tierSize));
let legendHTML = '';
const roundUp = (num, factor) => Math.ceil(num / factor) * factor;
for (let i = 0; i < tierCount; i++) {
let lowerBound = minPoints + (i * tierSize);
if (i > 0) lowerBound = roundUp(minPoints + (i * tierSize), 500) + 1;
let upperBound = minPoints + ((i + 1) * tierSize);
if (i < tierCount - 1) upperBound = roundUp(upperBound, 500);
if (range === 0) lowerBound = upperBound = minPoints;
legendHTML += `<div class="legend-item"><div class="legend-color-box price-tier-${i+1}"></div> ${lowerBound.toLocaleString()} - ${upperBound.toLocaleString()}</div>`;
}
legend.innerHTML = legendHTML;
Array.from(grid.children).forEach(dayEl => {
const dateMatch = dayEl.title.match(/^\d{4}-\d{2}-\d{2}|Check in (\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
const date = dateMatch[1] || dateMatch[0];
const dayData = priceMap.get(date);
if (dayData) {
dayEl.classList.remove('off-peak', 'standard', 'peak');
let tier = 0;
while(tier < buckets.length -1 && dayData.totalPoints > buckets[tier]) {
tier++;
}
dayEl.classList.add(`price-tier-${tier + 1}`);
}
}
});
} else {
const pointTiers = getPointsTiers(priceMap);
const legendOrder = [ { key: 'off-peak', label: 'Off-Peak' }, { key: 'standard', label: 'Standard' }, { key: 'peak', label: 'Peak' } ];
let legendHTML = '';
for (const item of legendOrder) {
const tierKey = item.key.replace('-', '_');
const points = pointTiers[tierKey];
if (points) {
legendHTML += `<div class="legend-item"><div class="legend-color-box calendar-day ${item.key}"></div> ${item.label} (${points.toLocaleString()})</div>`;
}
}
legend.innerHTML = legendHTML;
}
}
losSelector.addEventListener('change', async (e) => {
const newLos = parseInt(e.target.value, 10);
if (newLos > 1) {
toggleContainer.classList.remove('hidden');
} else {
toggleContainer.classList.add('hidden');
isTotalView = false;
totalViewToggle.checked = false;
}
grid.style.opacity = '0.5';
const result = await fetchAllData(hotelCode, newLos);
currentAllData = result.allData;
updateRoomSelector(currentAllData);
render(roomSelector.value);
grid.style.opacity = '1';
});
totalViewToggle.addEventListener('change', () => {
isTotalView = totalViewToggle.checked;
render(roomSelector.value);
});
roomSelector.addEventListener('change', () => render(roomSelector.value));
closeBtn.addEventListener('click', () => overlay.classList.add('hidden'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.add('hidden'); });
updateRoomSelector(currentAllData);
if (roomSelector.value) {
render(roomSelector.value);
}
return overlay;
}
function createMultiVisualizerUI(initialHotelResults) {
let hotelResults = initialHotelResults;
let isComparisonMode = false;
const overlay = document.createElement('div');
overlay.className = 'visualizer-overlay hidden';
const modal = document.createElement('div');
modal.className = 'visualizer-modal';
const header = document.createElement('div');
header.className = 'modal-header';
const title = document.createElement('h3');
title.textContent = 'Points Calendar Comparison';
header.appendChild(title);
const headerRight = document.createElement('div');
headerRight.className = 'modal-header-right';
const toggleContainer = document.createElement('div');
toggleContainer.className = 'comparison-toggle-container';
toggleContainer.innerHTML = `
<span>Compare Prices</span>
<label class="switch">
<input type="checkbox" id="comparisonToggle">
<span class="slider"></span>
</label>
`;
const toggle = toggleContainer.querySelector('#comparisonToggle');
const roomSelector = document.createElement('select');
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close-btn';
closeBtn.innerHTML = `×`;
headerRight.append(toggleContainer, roomSelector, closeBtn);
header.appendChild(headerRight);
const genericLegend = document.createElement('div');
genericLegend.className = 'generic-points-legend';
const multiChartContainer = document.createElement('div');
multiChartContainer.className = 'multi-chart-container';
const holidayLegend = document.createElement('div');
holidayLegend.className = 'holiday-legend';
const disclaimer = document.createElement('div');
disclaimer.className = 'availability-disclaimer';
const losSelector = document.createElement('select');
for (let i = 1; i <= 10; i++) {
losSelector.options.add(new Option(i, i));
}
disclaimer.append('Availability based on ', losSelector, ' night(s) stay.');
modal.append(header, genericLegend, multiChartContainer, holidayLegend, disclaimer);
overlay.appendChild(modal);
let holidayLegendHTML = '';
const chronologicalHolidays = Array.from(holidays.values());
const addedHolidays = new Set();
for(const holiday of chronologicalHolidays){
if(!addedHolidays.has(holiday.name)){
holidayLegendHTML += `<div class="legend-item"><div class="legend-color-box" style="border-color: ${holiday.color};"></div> ${holiday.name}</div>`;
addedHolidays.add(holiday.name);
}
}
holidayLegend.innerHTML = holidayLegendHTML;
function updateRoomSelector() {
const currentSelection = roomSelector.value;
const availableRoomTypes = new Set();
hotelResults.forEach(result => {
Object.keys(result.allData).forEach(roomType => {
if (result.allData[roomType].size > 0) {
availableRoomTypes.add(roomType);
}
});
});
const roomTypeNames = { STANDARD_ROOM: "Standard Room", CLUB: "Club Access", STANDARD_SUITE: "Standard Suite", PREMIUM_SUITE: "Premium Suite" };
const roomOrder = ["STANDARD_ROOM", "CLUB", "STANDARD_SUITE", "PREMIUM_SUITE"];
let optionsHTML = '';
for (const roomType of roomOrder) {
if (availableRoomTypes.has(roomType)) {
optionsHTML += `<option value="${roomType}">${roomTypeNames[roomType]}</option>`;
}
}
roomSelector.innerHTML = optionsHTML;
if (Array.from(roomSelector.options).some(opt => opt.value === currentSelection)) {
roomSelector.value = currentSelection;
}
}
function renderDefaultView(roomType) {
genericLegend.innerHTML = `
<div class="legend-item"><div class="legend-color-box calendar-day off-peak"></div> Off-Peak</div>
<div class="legend-item"><div class="legend-color-box calendar-day standard"></div> Standard</div>
<div class="legend-item"><div class="legend-color-box calendar-day peak"></div> Peak</div>
`;
multiChartContainer.innerHTML = '';
const displayMode = parseInt(losSelector.value, 10) > 1 ? 'total' : 'default';
hotelResults.forEach(result => {
const priceMap = result.allData[roomType];
if (priceMap && priceMap.size > 0) {
const hotelContainer = document.createElement('div');
const titleEl = document.createElement('h4');
const nameSpan = document.createElement('span');
nameSpan.textContent = `${result.hotelName} ${result.category || ''}`.trim();
titleEl.appendChild(nameSpan);
if (priveHotelCodes.has(result.hotelCode)) {
const priveLink = document.createElement('a');
priveLink.href = 'https://www.hotelft.com/preferred-program/hyatt-prive';
priveLink.textContent = 'Enjoy Privé benefits';
priveLink.target = '_blank';
priveLink.className = 'prive-link-btn';
titleEl.appendChild(priveLink);
}
const legend = document.createElement('div');
legend.className = 'hotel-legend';
const pointTiers = getPointsTiers(priceMap);
const legendOrder = [ { key: 'off-peak', label: 'Off-Peak' }, { key: 'standard', label: 'Standard' }, { key: 'peak', label: 'Peak' } ];
let legendHTML = '';
for (const item of legendOrder) {
const tierKey = item.key.replace('-', '_');
const points = pointTiers[tierKey];
if (points) {
legendHTML += `<div class="legend-item"><div class="legend-color-box calendar-day ${item.key}"></div>${points.toLocaleString()}</div>`;
}
}
legend.innerHTML = legendHTML;
const chartContainer = document.createElement('div');
chartContainer.className = 'calendar-chart-container';
const monthsContainer = document.createElement('div');
monthsContainer.className = 'calendar-months';
const weeksContainer = document.createElement('div');
weeksContainer.className = 'calendar-weeks';
const grid = document.createElement('div');
grid.className = 'points-calendar-grid';
generateCalendarGrid(grid, monthsContainer, weeksContainer, priceMap, result.hotelCode, losSelector, displayMode);
chartContainer.append(monthsContainer, weeksContainer, grid);
hotelContainer.append(titleEl, legend, chartContainer);
multiChartContainer.appendChild(hotelContainer);
}
});
}
function renderComparisonView(roomType) {
multiChartContainer.innerHTML = '';
const useTotalPoints = parseInt(losSelector.value, 10) > 1;
const allPoints = [];
hotelResults.forEach(result => {
const priceMap = result.allData[roomType];
if (priceMap) {
allPoints.push(...Array.from(priceMap.values()).map(d => useTotalPoints ? d.totalPoints : d.points));
}
});
if (allPoints.length === 0) {
renderDefaultView(roomType);
return;
}
const minPoints = Math.min(...allPoints);
const maxPoints = Math.max(...allPoints);
const range = maxPoints - minPoints;
const tierCount = 5;
const tierSize = range > 0 ? range / tierCount : 0;
const buckets = [];
let legendHTML = '';
if (useTotalPoints) {
legendHTML += `<span style="font-weight: bold; font-size: 11px;">Total for Stay:</span>`;
}
const roundUp = (num, factor) => Math.ceil(num / factor) * factor;
for (let i = 0; i < tierCount; i++) {
const upper = minPoints + ((i + 1) * tierSize);
buckets.push(upper);
let lowerBound = minPoints + (i * tierSize);
if (i > 0) lowerBound = roundUp(minPoints + (i * tierSize), 500) + 1;
let upperBound = upper;
if (i < tierCount - 1) upperBound = roundUp(upper, 500);
if (range === 0) lowerBound = upperBound = minPoints;
legendHTML += `<div class="legend-item"><div class="legend-color-box price-tier-${i+1}"></div> ${lowerBound.toLocaleString()} - ${upperBound.toLocaleString()}</div>`;
}
genericLegend.innerHTML = legendHTML;
hotelResults.forEach(result => {
const priceMap = result.allData[roomType];
if (priceMap && priceMap.size > 0) {
const hotelContainer = document.createElement('div');
const titleEl = document.createElement('h4');
const nameSpan = document.createElement('span');
nameSpan.textContent = `${result.hotelName} ${result.category || ''}`.trim();
titleEl.appendChild(nameSpan);
if (priveHotelCodes.has(result.hotelCode)) {
const priveLink = document.createElement('a');
priveLink.href = 'https://www.hotelft.com/preferred-program/hyatt-prive';
priveLink.textContent = 'Enjoy Privé benefits';
priveLink.target = '_blank';
priveLink.className = 'prive-link-btn';
titleEl.appendChild(priveLink);
}
const chartContainer = document.createElement('div');
chartContainer.className = 'calendar-chart-container';
const monthsContainer = document.createElement('div');
monthsContainer.className = 'calendar-months';
const weeksContainer = document.createElement('div');
weeksContainer.className = 'calendar-weeks';
const grid = document.createElement('div');
grid.className = 'points-calendar-grid';
const displayMode = useTotalPoints ? 'total' : 'default';
generateCalendarGrid(grid, monthsContainer, weeksContainer, priceMap, result.hotelCode, losSelector, displayMode);
Array.from(grid.children).forEach(dayEl => {
const dateMatch = dayEl.title.match(/^\d{4}-\d{2}-\d{2}|Check in (\d{4}-\d{2}-\d{2})/);
if (dateMatch) {
const date = dateMatch[1] || dateMatch[0];
const dayData = priceMap.get(date);
if (dayData) {
dayEl.classList.remove('off-peak', 'standard', 'peak');
const valueToCompare = useTotalPoints ? dayData.totalPoints : dayData.points;
let tier = 0;
while(tier < buckets.length -1 && valueToCompare > buckets[tier]) {
tier++;
}
dayEl.classList.add(`price-tier-${tier + 1}`);
}
}
});
chartContainer.append(monthsContainer, weeksContainer, grid);
hotelContainer.append(titleEl, chartContainer);
multiChartContainer.appendChild(hotelContainer);
}
});
}
function handleRender() {
if (isComparisonMode) {
renderComparisonView(roomSelector.value);
} else {
renderDefaultView(roomSelector.value);
}
}
toggle.addEventListener('change', () => {
isComparisonMode = toggle.checked;
handleRender();
});
roomSelector.addEventListener('change', handleRender);
losSelector.addEventListener('change', async () => {
multiChartContainer.style.opacity = '0.5';
[losSelector, roomSelector, toggle].forEach(el => el.disabled = true);
const hotelCodes = hotelResults.map(h => h.hotelCode);
const fetchPromises = hotelCodes.map(code => fetchAllData(code, losSelector.value));
const newHotelData = await Promise.all(fetchPromises);
hotelResults = hotelResults.map(originalHotel => {
const newData = newHotelData.find(res => res.hotelCode === originalHotel.hotelCode);
return { ...originalHotel, ...newData };
});
isComparisonMode = false;
toggle.checked = false;
updateRoomSelector();
renderDefaultView(roomSelector.value);
multiChartContainer.style.opacity = '1';
[losSelector, roomSelector, toggle].forEach(el => el.disabled = false);
});
closeBtn.addEventListener('click', () => overlay.classList.add('hidden'));
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.classList.add('hidden'); });
updateRoomSelector();
renderDefaultView(roomSelector.value);
return overlay;
}
function createAlertSetupModal(hotelName, hotelCode, dateString, currentLos, currentRoomType, onSetAlertsCallback) {
const overlay = document.createElement('div');
overlay.className = 'visualizer-overlay';
const modal = document.createElement('div');
modal.className = 'visualizer-modal';
modal.id = 'alertSetupModal';
overlay.appendChild(modal);
const header = document.createElement('div');
header.className = 'modal-header';
header.innerHTML = `<h3>Set Alerts for ${hotelName || hotelCode}</h3>`;
modal.appendChild(header);
const date = new Date(dateString + 'T00:00:00');
const weekday = date.toLocaleDateString('en-US', { weekday: 'long' });
const subheader = document.createElement('p');
subheader.textContent = `Check-in Date: ${dateString} (${weekday})`;
subheader.style.textAlign = 'center';
subheader.style.marginTop = '-10px';
subheader.style.marginBottom = '20px';
modal.appendChild(subheader);
const losContainer = document.createElement('div');
losContainer.innerHTML = '<h4>Lengths of Stay (Nights):</h4>';
const losCheckboxes = document.createElement('div');
losCheckboxes.className = 'checkbox-group';
for (let i = 1; i <= 10; i++) {
const div = document.createElement('div');
div.innerHTML = `<input type="checkbox" id="los-${i}" value="${i}" ${i == currentLos ? 'checked' : ''}><label for="los-${i}">${i}</label>`;
losCheckboxes.appendChild(div);
}
losContainer.appendChild(losCheckboxes);
const roomTypeContainer = document.createElement('div');
roomTypeContainer.innerHTML = '<h4>Room Types:</h4>';
const roomTypeCheckboxes = document.createElement('div');
roomTypeCheckboxes.className = 'checkbox-group';
const roomTypes = { STANDARD_ROOM: "Standard Room", CLUB: "Club Access", STANDARD_SUITE: "Standard Suite", PREMIUM_SUITE: "Premium Suite" };
Object.entries(roomTypes).forEach(([key, name]) => {
const div = document.createElement('div');
div.innerHTML = `<input type="checkbox" id="rt-${key}" value="${key}" ${key === currentRoomType ? 'checked' : ''}><label for="rt-${key}">${name}</label>`;
roomTypeCheckboxes.appendChild(div);
});
roomTypeContainer.appendChild(roomTypeCheckboxes);
modal.append(losContainer, roomTypeContainer);
const footer = document.createElement('div');
footer.className = 'modal-footer';
modal.appendChild(footer);
const setAlertsBtn = document.createElement('button');
setAlertsBtn.textContent = 'Set Selected Alerts';
setAlertsBtn.className = 'compare-btn';
footer.appendChild(setAlertsBtn);
setAlertsBtn.addEventListener('click', () => {
const selectedLos = Array.from(losContainer.querySelectorAll('input:checked')).map(cb => parseInt(cb.value, 10));
const selectedRoomTypes = Array.from(roomTypeContainer.querySelectorAll('input:checked')).map(cb => cb.value);
if (selectedLos.length === 0 || selectedRoomTypes.length === 0) {
alert('Please select at least one Length of Stay and one Room Type.');
return;
}
const combinations = [];
for (const los of selectedLos) {
for (const roomType of selectedRoomTypes) {
combinations.push({ los, roomType });
}
}
onSetAlertsCallback(combinations);
overlay.remove();
});
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close-btn';
closeBtn.innerHTML = `×`;
closeBtn.style.position = 'absolute';
closeBtn.style.top = '15px';
closeBtn.style.right = '20px';
header.appendChild(closeBtn);
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
return overlay;
}
function waitForElement(selector) {
return new Promise(resolve => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const targetEl = document.querySelector(selector);
if (targetEl) { observer.disconnect(); resolve(targetEl); }
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
async function main() {
if (window.location.href.includes('/explore-hotels/rate-calendar')) {
mainForCalendarPage();
} else if (window.location.href.includes('/explore-hotels/map')) {
mainForMapPage();
} else if (window.location.href.includes('/search/')) {
mainForSearchPage();
}
}
async function mainForCalendarPage() {
const hotelCode = getHotelCodeFromURL();
if (!hotelCode) return;
const injectionSelector = 'body > div.explore-hotels-content > main > div.vrc-cal-container > div.vrc-calendar > div.calendar-body-header > div.calendar-date-container';
const injectionPoint = await waitForElement(injectionSelector);
if (!injectionPoint) return;
const triggerBtn = document.createElement('button');
triggerBtn.className = 'haiyaa-trigger-btn';
triggerBtn.textContent = 'Haiyaa!';
injectionPoint.appendChild(triggerBtn);
triggerBtn.addEventListener('click', async () => {
const existingModal = document.querySelector('.visualizer-overlay');
if (existingModal) existingModal.remove();
triggerBtn.textContent = '...';
triggerBtn.disabled = true;
try {
const initialResult = await fetchAllData(hotelCode, 1);
const visualizerModal = createSingleVisualizerUI(initialResult);
document.body.appendChild(visualizerModal);
visualizerModal.classList.remove('hidden');
} catch (error) {
console.error('[Hyatt Visualizer] A critical error occurred:', error);
triggerBtn.textContent = 'Error!';
setTimeout(() => { triggerBtn.textContent = 'Haiyaa!'; triggerBtn.disabled = false; }, 2000);
return;
}
triggerBtn.textContent = 'Haiyaa!';
triggerBtn.disabled = false;
});
}
function createHotelSelectionModal(hotels, limit, callback) {
const overlay = document.createElement('div');
overlay.className = 'visualizer-overlay';
const modal = document.createElement('div');
modal.className = 'visualizer-modal';
modal.id = 'hotelSelectionModal';
overlay.appendChild(modal);
const header = document.createElement('div');
header.className = 'modal-header';
modal.appendChild(header);
const title = document.createElement('h3');
title.textContent = `Select up to ${limit} hotels`;
header.appendChild(title);
const closeBtn = document.createElement('button');
closeBtn.className = 'modal-close-btn';
closeBtn.innerHTML = `×`;
header.appendChild(closeBtn);
const list = document.createElement('ul');
modal.appendChild(list);
hotels.forEach(hotel => {
const listItem = document.createElement('li');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = hotel.spiritCode;
checkbox.id = `hotel-select-${hotel.spiritCode}`;
const label = document.createElement('label');
label.textContent = `${hotel.hotelName} ${hotel.category || ''}`.trim();
label.htmlFor = checkbox.id;
listItem.append(checkbox, label);
list.appendChild(listItem);
});
const footer = document.createElement('div');
footer.className = 'modal-footer';
modal.appendChild(footer);
const counter = document.createElement('span');
counter.className = 'selection-counter';
footer.appendChild(counter);
const compareBtn = document.createElement('button');
compareBtn.textContent = 'Compare Selections';
compareBtn.className = 'compare-btn';
footer.appendChild(compareBtn);
function updateState() {
const checked = list.querySelectorAll('input[type="checkbox"]:checked');
const count = checked.length;
counter.textContent = `${count} / ${limit} selected`;
compareBtn.disabled = count === 0;
const unchecked = list.querySelectorAll('input[type="checkbox"]:not(:checked)');
if (count >= limit) {
unchecked.forEach(cb => cb.disabled = true);
} else {
unchecked.forEach(cb => cb.disabled = false);
}
}
list.addEventListener('change', updateState);
compareBtn.addEventListener('click', () => {
const selectedCodes = new Set(Array.from(list.querySelectorAll('input:checked')).map(cb => cb.value));
const selectedHotels = hotels.filter(h => selectedCodes.has(h.spiritCode));
overlay.remove();
callback(selectedHotels);
});
closeBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
updateState();
return overlay;
}
async function processAndDisplayHotels(selectedHotels, triggerBtn) {
try {
triggerBtn.textContent = '...';
triggerBtn.disabled = true;
const staggerDelay = 200;
const promises = selectedHotels.map((hotel, index) => {
return new Promise(resolve => {
setTimeout(() => {
fetchAllData(hotel.spiritCode, 1).then(resolve);
}, index * (staggerDelay + (Math.random() * 100)));
});
});
const initialHotelData = await Promise.all(promises);
const hotelResults = initialHotelData.map(result => {
const originalHotel = selectedHotels.find(h => h.spiritCode === result.hotelCode);
return { ...result,
hotelName: (originalHotel && originalHotel.hotelName) || result.hotelCode,
category: (originalHotel && originalHotel.category) || ''
};
});
const visualizerModal = createMultiVisualizerUI(hotelResults);
document.body.appendChild(visualizerModal);
visualizerModal.classList.remove('hidden');
} catch (error) {
console.error('[Hyatt Visualizer] A critical error occurred on map page:', error);
triggerBtn.textContent = 'Error!';
} finally {
triggerBtn.textContent = 'Haiyaa!';
triggerBtn.disabled = false;
}
}
async function mainForMapPage() {
const injectionSelector = 'div[data-locator="num-results-heading"]';
const injectionPoint = await waitForElement(injectionSelector);
if (!injectionPoint) return;
const triggerBtn = document.createElement('button');
triggerBtn.className = 'haiyaa-trigger-btn';
triggerBtn.textContent = 'Haiyaa!';
triggerBtn.style.display = 'none';
injectionPoint.appendChild(triggerBtn);
const observer = new MutationObserver(() => {
const calendarLinks = document.querySelectorAll('a[href*="/explore-hotels/rate-calendar?spiritCode="]');
triggerBtn.style.display = calendarLinks.length > 0 ? 'inline-block' : 'none';
});
observer.observe(document.body, { childList: true, subtree: true });
triggerBtn.addEventListener('click', async () => {
const existingModal = document.querySelector('.visualizer-overlay');
if (existingModal) existingModal.remove();
const limit = 8;
const scrapedHotels = Array.from(document.querySelectorAll('a[href*="/explore-hotels/rate-calendar?spiritCode="]')).map(linkEl => {
const spiritCode = new URLSearchParams(linkEl.search).get('spiritCode');
if (!spiritCode) return null;
const hotelCard = linkEl.closest('div[role="listitem"]');
const hotelNameEl = hotelCard ? hotelCard.querySelector('[data-locator="hotel-name-long"]') : document.querySelector(`#modal-card-label-${spiritCode} [data-locator="hotel-name-long"]`);
const hotelName = hotelNameEl ? hotelNameEl.textContent.trim() : spiritCode;
const category = hotelCard ? getCategoryFromDOM(hotelCard) : getCategoryFromDOM(document.querySelector(`#modal-card-label-${spiritCode}`)?.closest('div.hotel-info-container'));
return { hotelName, spiritCode, category };
}).filter((h, index, self) => h && self.findIndex(ho => ho.spiritCode === h.spiritCode) === index);
const selectionModal = createHotelSelectionModal(scrapedHotels, limit, (selectedHotels) => {
processAndDisplayHotels(selectedHotels, triggerBtn);
});
document.body.appendChild(selectionModal);
});
}
async function mainForSearchPage() {
const injectionSelector = '[class*="styles_control-bar__results-text"]';
const resultsTextElement = await waitForElement(injectionSelector);
if (!resultsTextElement) {
console.error('[Hyatt Visualizer] Could not find injection point on search page.');
return;
}
const injectionPoint = resultsTextElement.parentElement;
const triggerBtn = document.createElement('button');
triggerBtn.className = 'haiyaa-trigger-btn';
triggerBtn.textContent = 'Haiyaa!';
injectionPoint.insertBefore(triggerBtn, resultsTextElement);
triggerBtn.addEventListener('click', async () => {
const existingModal = document.querySelector('.visualizer-overlay');
if (existingModal) existingModal.remove();
const limit = 8;
const hotelCards = document.querySelectorAll('div[data-spirit-code]');
const scrapedHotels = Array.from(hotelCards).map(card => {
const spiritCode = card.dataset.spiritCode;
if (!spiritCode) return null;
const categoryEl = card.querySelector('[class*="RatingAndDistance_ratingAndDistance_redesign"]');
let category = '';
if (categoryEl) {
const match = categoryEl.textContent.match(/Category ([A-Z0-9])/);
if (match && match[1]) {
category = `[Cat ${match[1]}]`;
}
}
if (!category) {
return null;
}
const nameEl = card.querySelector('[class*="HotelCard_info__header-wrapper"] > div');
const hotelName = nameEl ? nameEl.textContent.trim() : spiritCode;
return { hotelName, spiritCode, category };
}).filter((h, index, self) => h && self.findIndex(ho => ho.spiritCode === h.spiritCode) === index);
if (scrapedHotels.length === 0) {
alert('No hotels with points calendar links found on this page.');
return;
}
const selectionModal = createHotelSelectionModal(scrapedHotels, limit, (selectedHotels) => {
processAndDisplayHotels(selectedHotels, triggerBtn);
});
document.body.appendChild(selectionModal);
});
}
main();
})();