From ed98d4ea7053b11a8c98ef849221355726f7be13 Mon Sep 17 00:00:00 2001 From: TopherMayor Date: Fri, 1 May 2026 10:44:14 -0700 Subject: [PATCH] Add bundles tab for package pricing --- public/index.html | 172 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 132 insertions(+), 40 deletions(-) diff --git a/public/index.html b/public/index.html index cd38a1d..861097c 100644 --- a/public/index.html +++ b/public/index.html @@ -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(`
Flights included
@@ -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 => ` `).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 = `
🗳️
No options yet. Be the first to add one below!
`; + 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 = `
🗳️
${emptyCopy}
`; 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 - ? `