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(`