Add bundles tab for package pricing

This commit is contained in:
TopherMayor
2026-05-01 10:44:14 -07:00
parent 09cf482d92
commit ed98d4ea70

View File

@@ -1698,6 +1698,7 @@
let pendingVoteOptionId = null;
let pendingVoteRemove = false;
let pendingStableOptionOrder = null;
const BUNDLE_TAB_ID = 'bundles';
// ── Init ───────────────────────────────────────────────────
function init() {
@@ -1721,8 +1722,9 @@
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
const num = parseInt(e.key);
if (num >= 1 && num <= state.categories.length) {
setTab(state.categories[num - 1].id);
const tabIds = getPrimaryTabIds();
if (num >= 1 && num <= tabIds.length) {
setTab(tabIds[num - 1]);
}
});
@@ -2039,6 +2041,72 @@
return [typeLabel, basisLabel].filter(Boolean).join(' · ');
}
function getTabDefinitions() {
const tabs = Array.isArray(state.categories) ? state.categories.map((cat) => ({ ...cat })) : [];
const bundleTab = { id: BUNDLE_TAB_ID, name: 'Bundles', emoji: '🧳' };
const hotelIndex = tabs.findIndex((cat) => cat.id === 'hotel');
if (hotelIndex >= 0) {
tabs.splice(hotelIndex + 1, 0, bundleTab);
} else {
tabs.unshift(bundleTab);
}
return tabs;
}
function getPrimaryTabIds() {
return getTabDefinitions().map((tab) => tab.id);
}
function getTabMeta(id) {
if (id === 'map') return { id: 'map', name: 'Map', emoji: '🗺️' };
return getTabDefinitions().find((tab) => tab.id === id) || { id, name: id, emoji: '📁' };
}
function isBundleSource(source) {
return String(source?.bookingType || '').toLowerCase() === 'package';
}
function isStandaloneComponentTab(tabId) {
return ['hotel', 'flight', 'golf', 'nightlife', 'excursion'].includes(tabId);
}
function isPackageLink(link) {
const haystack = `${link?.label || ''} ${link?.url || ''}`.toLowerCase();
return /\b(costco|apple vacations|cheapcaribbean|package|bundle|flight[- ]?hotel|hotel[- ]?flight)\b/.test(haystack);
}
function getVisibleLinksForTab(opt, tabId = activeTab) {
const links = Array.isArray(opt.links) ? opt.links : [];
if (tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId)) {
return links;
}
return links.filter((link) => !isPackageLink(link));
}
function getAvailableSourcesForTab(opt, tabId = activeTab) {
const sources = getAvailableSources(opt);
if (tabId === BUNDLE_TAB_ID) {
return sources.filter(isBundleSource);
}
if (isStandaloneComponentTab(tabId)) {
return sources.filter((source) => !isBundleSource(source));
}
return sources;
}
function getVisibleOptionsForTab(tabId = activeTab) {
const sourcesForTab = (opt) => getAvailableSourcesForTab(opt, tabId).length > 0;
if (tabId === BUNDLE_TAB_ID) {
return state.options.filter((opt) => opt.approved && sourcesForTab(opt));
}
return state.options.filter((opt) => opt.categoryId === tabId && opt.approved && sourcesForTab(opt));
}
function getTabOptionCount(tabId) {
if (tabId === 'map' || tabId === 'results') return '';
return String(getVisibleOptionsForTab(tabId).length);
}
function normalizeSourceKey(value) {
return String(value || '')
.trim()
@@ -2071,8 +2139,11 @@
}];
}
function getOptionSelectedSourceKey(opt) {
const availableSources = getAvailableSources(opt);
function getOptionSelectedSourceKey(opt, tabId = activeTab) {
const availableSources = getAvailableSourcesForTab(opt, tabId);
if (!availableSources.length) {
return '';
}
const stored = state.priceSourceSelections?.[opt.id];
const normalizedStored = stored ? normalizeSourceKey(stored) : '';
if (normalizedStored && availableSources.some(source => source.sourceKey === normalizedStored)) {
@@ -2085,17 +2156,17 @@
: availableSources[0]?.sourceKey || 'unknown-source';
}
function getOptionSourceSeries(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
const normalizedKey = normalizeSourceKey(sourceKey);
function getOptionSourceSeries(opt, sourceKey, tabId = activeTab) {
const normalizedKey = normalizeSourceKey(sourceKey || getOptionSelectedSourceKey(opt, tabId));
if (opt.priceHistoryBySource && Array.isArray(opt.priceHistoryBySource[normalizedKey])) {
return opt.priceHistoryBySource[normalizedKey];
}
return Array.isArray(opt.priceHistory) ? opt.priceHistory : [];
}
function getOptionSourceMeta(opt, sourceKey = getOptionSelectedSourceKey(opt)) {
const normalizedKey = normalizeSourceKey(sourceKey);
return getAvailableSources(opt).find(source => source.sourceKey === normalizedKey) || null;
function getOptionSourceMeta(opt, sourceKey, tabId = activeTab) {
const normalizedKey = normalizeSourceKey(sourceKey || getOptionSelectedSourceKey(opt, tabId));
return getAvailableSourcesForTab(opt, tabId).find(source => source.sourceKey === normalizedKey) || null;
}
function setOptionSource(optionId, sourceKey) {
@@ -2129,8 +2200,8 @@
return new Map(state.options.map((opt, index) => [opt.id, index]));
}
function getSelectedOptionPrice(opt) {
const selectedSeries = getOptionSourceSeries(opt);
function getSelectedOptionPrice(opt, tabId = activeTab) {
const selectedSeries = getOptionSourceSeries(opt, undefined, tabId);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
return typeof selectedPoint?.price === 'number' ? selectedPoint.price : null;
}
@@ -2150,8 +2221,8 @@
return ascending ? aVotes - bVotes : bVotes - aVotes;
}
} else {
const aPrice = getSelectedOptionPrice(a);
const bPrice = getSelectedOptionPrice(b);
const aPrice = getSelectedOptionPrice(a, activeTab);
const bPrice = getSelectedOptionPrice(b, activeTab);
const aHasPrice = typeof aPrice === 'number';
const bHasPrice = typeof bPrice === 'number';
@@ -2243,7 +2314,7 @@
}
function renderSourceSelect(opt) {
const sources = getAvailableSources(opt);
const sources = getAvailableSourcesForTab(opt);
if (sources.length <= 1) {
const source = sources[0] || null;
const sourceLabel = source?.sourceLabel || opt.automationInsights?.source || 'Unknown source';
@@ -2274,11 +2345,12 @@
}
function renderOptionFacts(opt) {
const availableSources = getAvailableSources(opt);
const selectedSourceKey = getOptionSelectedSourceKey(opt);
const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey);
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
const selectedPoint = selectedSeries.at(-1) || opt.latestPricePoint || null;
const tabId = activeTab;
const availableSources = getAvailableSourcesForTab(opt, tabId);
const selectedSourceKey = getOptionSelectedSourceKey(opt, tabId);
const selectedSeries = getOptionSourceSeries(opt, selectedSourceKey, tabId);
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey, tabId);
const selectedPoint = selectedSeries.at(-1) || ((tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId)) ? opt.latestPricePoint : null);
const insights = selectedPoint ? {
source: selectedMeta?.sourceLabel || selectedPoint.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || selectedPoint.sourceUrl || null,
@@ -2288,7 +2360,16 @@
decisionNote: selectedPoint.decisionNote || null,
displayPrice: selectedPoint.displayPrice || null,
currency: selectedPoint.currency || 'USD',
} : (opt.automationInsights || {});
} : {
source: selectedMeta?.sourceLabel || opt.automationInsights?.source || 'Automation feed',
sourceUrl: selectedMeta?.sourceUrl || opt.automationInsights?.sourceUrl || null,
bookingType: selectedMeta?.bookingType || null,
priceBasis: selectedMeta?.priceBasis || null,
availability: null,
decisionNote: null,
displayPrice: null,
currency: 'USD',
};
const currentPrice = typeof selectedPoint?.price === 'number' ? formatCurrency(selectedPoint.price, insights.currency || 'USD') : '';
const priceLabel = currentPrice || insights.displayPrice || 'Not yet tracked';
const priceContext = getPointContext(selectedPoint);
@@ -2298,12 +2379,16 @@
selectedMeta?.priceBasis || insights.priceBasis,
);
const statusLabel = selectedPoint?.availability || selectedPoint?.decisionNote || insights.availability || insights.decisionNote || 'Matched from live search';
const overviewItems = normalizeTextList(opt.details);
const autoHighlights = normalizeTextList(selectedPoint?.highlights || opt.automationInsights?.highlights);
const features = normalizeTextList(selectedPoint?.features || opt.automationInsights?.features);
const amenities = normalizeTextList(selectedPoint?.amenities || opt.automationInsights?.amenities);
const inclusions = normalizeTextList(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
const limitations = normalizeTextList(selectedPoint?.limitations || opt.automationInsights?.limitations);
const packageDetailPattern = /\b(package|bundle|bundled|costco|apple|flight[- ]?included|transfer[- ]included|flight\+hotel|hotel\+flight)\b/i;
const filterStandaloneDetails = (items) => (tabId === BUNDLE_TAB_ID || !isStandaloneComponentTab(tabId))
? normalizeTextList(items)
: normalizeTextList(items).filter((item) => !packageDetailPattern.test(item));
const overviewItems = filterStandaloneDetails(opt.details);
const autoHighlights = filterStandaloneDetails(selectedPoint?.highlights || opt.automationInsights?.highlights);
const features = filterStandaloneDetails(selectedPoint?.features || opt.automationInsights?.features);
const amenities = filterStandaloneDetails(selectedPoint?.amenities || opt.automationInsights?.amenities);
const inclusions = filterStandaloneDetails(selectedPoint?.inclusions || opt.automationInsights?.inclusions);
const limitations = filterStandaloneDetails(selectedPoint?.limitations || opt.automationInsights?.limitations);
const includedComponents = normalizeTextList(selectedPoint?.includedComponents || opt.automationInsights?.includedComponents);
const flightsIncluded = includedComponents.some(item => /flight/i.test(item));
const sourceMetaLine = selectedMeta
@@ -2380,7 +2465,7 @@
`);
}
if (opt.categoryId === 'hotel' && flightsIncluded) {
if (tabId === BUNDLE_TAB_ID && flightsIncluded) {
sections.push(`
<div class="option-detail-section">
<div class="option-detail-title">Flights included</div>
@@ -2404,10 +2489,11 @@
}
function renderPriceTrend(opt) {
const selectedSourceKey = getOptionSelectedSourceKey(opt);
const series = getOptionSourceSeries(opt, selectedSourceKey)
const tabId = activeTab;
const selectedSourceKey = getOptionSelectedSourceKey(opt, tabId);
const series = getOptionSourceSeries(opt, selectedSourceKey, tabId)
.filter(point => typeof point.price === 'number' && !Number.isNaN(point.price));
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey);
const selectedMeta = getOptionSourceMeta(opt, selectedSourceKey, tabId);
if (series.length === 0) {
return `
@@ -2591,7 +2677,7 @@
// ── Tabs ───────────────────────────────────────────────────
function renderTabs() {
const bar = document.getElementById('tabsBar');
const catsWithMap = [...state.categories, { id: 'map', name: 'Map', emoji: '🗺️' }];
const catsWithMap = [...getTabDefinitions(), { id: 'map', name: 'Map', emoji: '🗺️' }];
bar.innerHTML = catsWithMap.map(cat => `
<div class="tab${cat.id === activeTab ? ' active' : ''}"
role="tab"
@@ -2603,15 +2689,15 @@
onkeydown="handleTabKey(event, '${cat.id}')">
<span class="tab-emoji">${cat.emoji}</span>
${cat.name}
<span class="tab-count" id="tab-count-${cat.id}">${cat.id === 'results' ? '' : cat.id === 'map' ? '' : state.options.filter(o => o.categoryId === cat.id).length}</span>
<span class="tab-count" id="tab-count-${cat.id}">${getTabOptionCount(cat.id)}</span>
</div>
`).join('');
bar.setAttribute('role', 'tablist');
bar.setAttribute('aria-label', 'Voting categories');
bar.setAttribute('aria-label', 'Trip tabs');
}
function handleTabKey(event, catId) {
const cats = [...state.categories.map(c => c.id), 'map'];
const cats = [...getPrimaryTabIds(), 'map'];
const idx = cats.indexOf(catId);
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
@@ -2644,7 +2730,7 @@
optionsList.style.display = 'block';
if (addSection) addSection.style.display = '';
}
document.getElementById('optionsList')?.setAttribute('aria-label', state.categories.find(c => c.id === id)?.name + ' options');
document.getElementById('optionsList')?.setAttribute('aria-label', `${getTabMeta(id)?.name || id} options`);
}
// ── Render options ────────────────────────────────────────
@@ -2713,13 +2799,16 @@
}
// ── Regular voting tabs ─────────────────────────────────
const opts = state.options.filter(o => o.categoryId === activeTab && o.approved);
const opts = getVisibleOptionsForTab(activeTab);
document.getElementById('totalVotersCount').textContent =
state.totalVoters ? `👥 ${state.totalVoters} voter${state.totalVoters !== 1 ? 's' : ''}` : '';
if (opts.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="empty-emoji">🗳️</div>No options yet. Be the first to add one below!</div>`;
const emptyCopy = activeTab === BUNDLE_TAB_ID
? 'No bundle options yet. Check back after the next live pricing run.'
: 'No options yet. Be the first to add one below!';
list.innerHTML = `<div class="empty-state"><div class="empty-emoji">🗳️</div>${emptyCopy}</div>`;
return;
}
@@ -2733,11 +2822,14 @@
const votePct = maxVotes > 0 ? (voteEntries.length / maxVotes * 100) : 0;
const hasVoted = state.voterName && voteEntries.some(v => v.name === state.voterName);
const voteList = voteEntries.map(v => v.name).join(', ');
const linkPills = opt.links && opt.links.length
? `<div class="option-links">${opt.links.map(link => `
const visibleLinks = getVisibleLinksForTab(opt, activeTab);
const linkPills = visibleLinks.length
? `<div class="option-links">${visibleLinks.map(link => `
<a href="${link.url}" target="_blank" rel="noopener noreferrer" class="option-pill" onclick="event.stopPropagation()">${link.label}</a>
`).join('')}</div>`
: (opt.url ? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>` : '');
: (opt.url && !isPackageLink({ label: opt.name, url: opt.url })
? `<a href="${opt.url}" target="_blank" rel="noopener noreferrer" class="option-link" onclick="event.stopPropagation()">🔗 ${opt.url.replace(/^https?:\/\//, '').split('/')[0]}</a>`
: '');
return `
<div class="option-card${hasVoted ? ' voted' : ''}" data-option-id="${escapeHtml(opt.id)}">