← back to Wine Finder
public/js/dynamic-search.js
397 lines
/**
* Dynamic Search Enhancement
* Real-time search, advanced filters, and stock-style price charts
*/
// State
let searchTimeout = null;
let previousSearchResults = [];
let currentViewMode = 'grid';
// DOM Elements
const searchInput = document.getElementById('searchInput');
const clearSearchBtn = document.getElementById('clearSearch');
const searchResultCount = document.getElementById('searchResultCount');
const toggleFiltersBtn = document.getElementById('toggleFilters');
const advancedFilters = document.getElementById('advancedFilters');
const filterChevron = document.getElementById('filterChevron');
const statsSection = document.getElementById('statsSection');
// Filter elements
const minPriceSlider = document.getElementById('minPrice');
const maxPriceSlider = document.getElementById('maxPrice');
const minRatingSlider = document.getElementById('minRating');
const vintageYearSlider = document.getElementById('vintageYear');
const countryFilter = document.getElementById('countryFilter');
const applyFiltersBtn = document.getElementById('applyFilters');
const resetFiltersBtn = document.getElementById('resetFilters');
// View mode buttons
const viewModeGrid = document.getElementById('viewModeGrid');
const viewModeList = document.getElementById('viewModeList');
// Initialize dynamic search
function initDynamicSearch() {
// Real-time search with debouncing
searchInput.addEventListener('input', (e) => {
const query = e.target.value.trim();
// Show/hide clear button
clearSearchBtn.style.display = query ? 'flex' : 'none';
// Debounce search
clearTimeout(searchTimeout);
if (query.length >= 2) {
searchTimeout = setTimeout(() => {
performDynamicSearch(query);
}, 500); // 500ms debounce
} else if (query.length === 0) {
hideResults();
statsSection.style.display = 'none';
searchResultCount.textContent = '';
}
});
// Clear search
clearSearchBtn.addEventListener('click', () => {
searchInput.value = '';
clearSearchBtn.style.display = 'none';
hideResults();
statsSection.style.display = 'none';
searchResultCount.textContent = '';
});
// Toggle advanced filters
toggleFiltersBtn.addEventListener('click', () => {
const isHidden = advancedFilters.style.display === 'none';
advancedFilters.style.display = isHidden ? 'block' : 'none';
filterChevron.textContent = isHidden ? '▲' : '▼';
advancedFilters.classList.toggle('filters-open', isHidden);
});
// Filter range sliders - update values
minPriceSlider.addEventListener('input', updatePriceRangeDisplay);
maxPriceSlider.addEventListener('input', updatePriceRangeDisplay);
minRatingSlider.addEventListener('input', () => {
document.getElementById('ratingValue').textContent = `${minRatingSlider.value}+`;
});
vintageYearSlider.addEventListener('input', () => {
const year = vintageYearSlider.value;
document.getElementById('vintageValue').textContent = year === '1990' ? 'Any' : `${year}+`;
});
// Apply filters
applyFiltersBtn.addEventListener('click', () => {
if (currentResults.length > 0) {
sortAndDisplayResults();
}
});
// Reset filters
resetFiltersBtn.addEventListener('click', resetAllFilters);
// View mode toggle
viewModeGrid.addEventListener('click', () => setViewMode('grid'));
viewModeList.addEventListener('click', () => setViewMode('list'));
}
// Update price range display
function updatePriceRangeDisplay() {
let minPrice = parseInt(minPriceSlider.value);
let maxPrice = parseInt(maxPriceSlider.value);
// Ensure min doesn't exceed max
if (minPrice > maxPrice) {
const temp = maxPrice;
maxPrice = minPrice;
minPrice = temp;
minPriceSlider.value = minPrice;
maxPriceSlider.value = maxPrice;
}
const maxText = maxPrice >= 500 ? '$500+' : `$${maxPrice}`;
document.getElementById('priceRangeValue').textContent = `$${minPrice} - ${maxText}`;
}
// Reset all filters
function resetAllFilters() {
minPriceSlider.value = 0;
maxPriceSlider.value = 500;
minRatingSlider.value = 0;
vintageYearSlider.value = 1990;
countryFilter.value = '';
document.getElementById('priceRangeValue').textContent = '$0 - $500+';
document.getElementById('ratingValue').textContent = '0.0+';
document.getElementById('vintageValue').textContent = 'Any';
if (currentResults.length > 0) {
sortAndDisplayResults();
}
}
// Perform dynamic search
async function performDynamicSearch(query) {
showLoading();
updateSearchResultCount('Searching...');
try {
// Get selected sources
const sources = [];
document.querySelectorAll('.source-checkbox input:checked').forEach(checkbox => {
sources.push(checkbox.value);
});
const response = await fetch('/api/scraper/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query,
sources: sources.length > 0 ? sources : ['vivino', 'totalwine', 'ucdavis-ava']
})
});
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
if (data.success && data.wines) {
currentResults = data.wines;
previousSearchResults = [...currentResults];
displayResults(data.wines);
updateSearchStats(data.wines);
updateSearchResultCount(`${data.wines.length} result${data.wines.length !== 1 ? 's' : ''}`);
statsSection.style.display = 'block';
} else {
showNoResults();
statsSection.style.display = 'none';
}
} catch (error) {
console.error('Dynamic search error:', error);
showNoResults();
updateSearchResultCount('Error');
} finally {
hideLoading();
}
}
// Update search result count badge
function updateSearchResultCount(text) {
searchResultCount.textContent = text;
searchResultCount.style.display = text ? 'inline-block' : 'none';
}
// Update search statistics
function updateSearchStats(wines) {
const totalResults = wines.length;
const winesWithPrices = wines.filter(w => w.price && w.price > 0);
const winesWithRatings = wines.filter(w => w.rating && w.rating > 0);
const avgPrice = winesWithPrices.length > 0
? winesWithPrices.reduce((sum, w) => sum + w.price, 0) / winesWithPrices.length
: 0;
const avgRating = winesWithRatings.length > 0
? winesWithRatings.reduce((sum, w) => sum + w.rating, 0) / winesWithRatings.length
: 0;
const sources = new Set(wines.map(w => w.source)).size;
// Calculate price change vs previous search
let priceChange = 0;
let priceChangePercent = 0;
if (previousSearchResults.length > 0) {
const prevWinesWithPrices = previousSearchResults.filter(w => w.price && w.price > 0);
const prevAvgPrice = prevWinesWithPrices.length > 0
? prevWinesWithPrices.reduce((sum, w) => sum + w.price, 0) / prevWinesWithPrices.length
: 0;
if (prevAvgPrice > 0) {
priceChange = avgPrice - prevAvgPrice;
priceChangePercent = (priceChange / prevAvgPrice) * 100;
}
}
// Update stat cards with animation
animateStatValue('statTotalResults', totalResults);
animateStatValue('statAvgPrice', `$${avgPrice.toFixed(2)}`);
animateStatValue('statAvgRating', avgRating.toFixed(1));
animateStatValue('statSourceCount', sources);
// Update price change indicator
const priceChangeEl = document.getElementById('statPriceChange');
if (priceChange !== 0) {
const changeText = `${priceChange > 0 ? '+' : ''}$${Math.abs(priceChange).toFixed(2)} (${priceChangePercent > 0 ? '+' : ''}${priceChangePercent.toFixed(1)}%)`;
priceChangeEl.textContent = changeText;
priceChangeEl.className = `stat-change ${priceChange > 0 ? 'positive' : 'negative'}`;
priceChangeEl.style.display = 'inline-block';
} else {
priceChangeEl.style.display = 'none';
}
}
// Animate stat value changes
function animateStatValue(elementId, value) {
const element = document.getElementById(elementId);
if (!element) return;
element.classList.add('stat-updating');
setTimeout(() => {
element.textContent = value;
element.classList.remove('stat-updating');
}, 150);
}
// Set view mode (grid/list)
function setViewMode(mode) {
currentViewMode = mode;
viewModeGrid.classList.toggle('active', mode === 'grid');
viewModeList.classList.toggle('active', mode === 'list');
const resultsTable = document.getElementById('resultsTable');
resultsTable.classList.toggle('grid-view', mode === 'grid');
resultsTable.classList.toggle('list-view', mode === 'list');
// Re-display results with new view
if (currentResults.length > 0) {
displayResults(currentResults);
}
}
// Enhanced chart creation with stock-style
window.createStockStyleChart = function(wineName, priceHistory) {
if (!priceHistory || priceHistory.length === 0) {
console.warn('No price history available');
return;
}
const chartSection = document.getElementById('chartSection');
const chartTitle = document.getElementById('chartTitle');
const chartCanvas = document.getElementById('priceChart');
chartSection.style.display = 'block';
chartTitle.textContent = `${wineName} - Price Trends`;
// Prepare data
const labels = priceHistory.map(p => new Date(p.timestamp).toLocaleDateString());
const prices = priceHistory.map(p => p.price);
const currentPrice = prices[prices.length - 1];
const firstPrice = prices[0];
const priceChange = currentPrice - firstPrice;
const priceChangePercent = ((priceChange / firstPrice) * 100);
const isPositive = priceChange >= 0;
// Update chart stats
document.getElementById('chartCurrentPrice').textContent = `$${currentPrice.toFixed(2)}`;
document.getElementById('chartPriceChange').textContent =
`${isPositive ? '+' : ''}$${priceChange.toFixed(2)} (${isPositive ? '+' : ''}${priceChangePercent.toFixed(2)}%)`;
document.getElementById('chartPriceChange').className =
`chart-price-change ${isPositive ? 'positive' : 'negative'}`;
// Create gradient
const ctx = chartCanvas.getContext('2d');
const gradient = ctx.createLinearGradient(0, 0, 0, 400);
gradient.addColorStop(0, isPositive ? 'rgba(16, 185, 129, 0.4)' : 'rgba(239, 68, 68, 0.4)');
gradient.addColorStop(1, isPositive ? 'rgba(16, 185, 129, 0.01)' : 'rgba(239, 68, 68, 0.01)');
// Destroy existing chart
if (currentChart) {
currentChart.destroy();
}
// Create new chart
currentChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Price (USD)',
data: prices,
borderColor: isPositive ? '#10b981' : '#ef4444',
backgroundColor: gradient,
borderWidth: 3,
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: '#ffffff',
pointBorderColor: isPositive ? '#10b981' : '#ef4444',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
plugins: {
legend: {
display: false
},
tooltip: {
backgroundColor: 'rgba(17, 17, 17, 0.95)',
titleColor: '#ffffff',
bodyColor: '#9ca3af',
padding: 12,
displayColors: false,
callbacks: {
label: function(context) {
return `$${context.parsed.y.toFixed(2)}`;
}
}
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
drawBorder: false
},
ticks: {
color: '#6b7280',
maxRotation: 45,
minRotation: 45
}
},
y: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
drawBorder: false
},
ticks: {
color: '#6b7280',
callback: function(value) {
return '$' + value.toFixed(0);
}
},
beginAtZero: false
}
}
}
});
// Timeframe buttons
document.querySelectorAll('.chart-timeframe-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.chart-timeframe-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
// TODO: Filter data by timeframe
});
});
};
// Initialize on DOM load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initDynamicSearch);
} else {
initDynamicSearch();
}