← back to Wine Finder
public/js/app.js
602 lines
// State management
let currentResults = [];
let currentChart = null;
// DOM Elements
const searchInput = document.getElementById('searchInput');
const searchBtn = document.getElementById('searchBtn');
const sortSelect = document.getElementById('sortSelect');
const resultsTable = document.getElementById('resultsTable');
const loadingIndicator = document.getElementById('loadingIndicator');
const noResults = document.getElementById('noResults');
const alertsSection = document.getElementById('alertsSection');
const alertsContainer = document.getElementById('alertsContainer');
const chartSection = document.getElementById('chartSection');
const chartTitle = document.getElementById('chartTitle');
const closeChartBtn = document.getElementById('closeChartBtn');
const logoutBtn = document.getElementById('logoutBtn');
// LocalStorage keys for UI preferences
const LS_SORT_KEY = 'wine-finder.sort';
// Initialize
document.addEventListener('DOMContentLoaded', () => {
// Hydrate saved sort preference BEFORE any list render
try {
const savedSort = localStorage.getItem(LS_SORT_KEY);
if (savedSort && sortSelect) {
const opt = Array.from(sortSelect.options).find(o => o.value === savedSort);
if (opt) sortSelect.value = savedSort;
}
} catch (e) { /* localStorage may be disabled */ }
setupEventListeners();
});
function setupEventListeners() {
// Search
searchBtn.addEventListener('click', handleSearch);
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
handleSearch();
}
});
// Quick search buttons
document.querySelectorAll('.quick-btn').forEach(btn => {
btn.addEventListener('click', () => {
const winery = btn.getAttribute('data-winery');
handleQuickSearch(winery);
});
});
// Sort
sortSelect.addEventListener('change', () => {
try { localStorage.setItem(LS_SORT_KEY, sortSelect.value); } catch (e) { /* noop */ }
sortAndDisplayResults();
});
// Chart close
closeChartBtn.addEventListener('click', () => {
chartSection.style.display = 'none';
if (currentChart) {
currentChart.destroy();
currentChart = null;
}
});
// Logout
logoutBtn.addEventListener('click', () => {
window.location.href = '/logout';
});
}
// Search handler
async function handleSearch() {
const query = searchInput.value.trim();
if (!query) {
alert('Please enter a search term');
return;
}
await performSearch({ query });
}
// Quick search handler
async function handleQuickSearch(winery) {
await performSearch({ winery });
}
// Perform search
async function performSearch(params) {
showLoading();
hideAlerts();
hideResults();
hideChart();
const btnText = searchBtn.querySelector('.btn-text');
const btnLoader = searchBtn.querySelector('.btn-loader');
try {
btnText.style.display = 'none';
btnLoader.style.display = 'inline-block';
searchBtn.disabled = true;
// Get selected sources
const sources = [];
document.querySelectorAll('.source-checkbox input:checked').forEach(checkbox => {
sources.push(checkbox.value);
});
// Add sources to params
params.sources = sources.length > 0 ? sources : ['kl', 'vivino', 'totalwine'];
const response = await fetch('/api/scraper/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(params),
credentials: 'same-origin' // Include session cookies
});
// Check for authentication errors
if (response.status === 401) {
window.location.href = '/login';
return;
}
const data = await response.json();
if (data.success) {
currentResults = data.wines || [];
// Show alerts if any
if (data.alerts && data.alerts.length > 0) {
displayAlerts(data.alerts);
}
// Display results
if (currentResults.length > 0) {
sortAndDisplayResults();
} else {
showNoResults();
}
} else {
const errorMsg = data.error || 'Unknown error occurred';
let detailedError = 'Search failed: ' + errorMsg;
// Add helpful context based on error type
if (errorMsg.includes('403') || errorMsg.includes('blocked') || errorMsg.includes('Forbidden')) {
detailedError += '\n\nNote: The K&L Wine website may be blocking our requests. The app is showing demo data to allow you to test features. Price tracking and alerts will resume when access is restored.';
} else if (errorMsg.includes('timeout') || errorMsg.includes('ETIMEDOUT')) {
detailedError += '\n\nThe wine retailer\'s website is taking too long to respond. Please try again in a few moments.';
} else if (errorMsg.includes('ENOTFOUND') || errorMsg.includes('DNS')) {
detailedError += '\n\nUnable to reach the wine retailer\'s website. Please check your internet connection.';
}
showError(detailedError);
showNoResults();
}
} catch (error) {
console.error('Search error:', error);
// Determine error type and show appropriate message
let errorMessage = 'Connection error. ';
if (error.name === 'TypeError' && error.message.includes('fetch')) {
errorMessage += 'Unable to reach the server. Please check your internet connection and try again.';
} else if (error.name === 'AbortError') {
errorMessage += 'Request timed out. The server may be experiencing high load. Please try again in a moment.';
} else if (error.message) {
errorMessage += error.message;
} else {
errorMessage += 'An unexpected error occurred. Please try again or contact support if the issue persists.';
}
// Show error in a more visible way
showError(errorMessage);
showNoResults();
} finally {
hideLoading();
btnText.style.display = 'inline';
btnLoader.style.display = 'none';
searchBtn.disabled = false;
}
}
// Sort and display results
function sortAndDisplayResults() {
const sortBy = sortSelect.value;
let sorted = [...currentResults];
switch (sortBy) {
case 'name':
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'price-asc':
sorted.sort((a, b) => (a.price || 0) - (b.price || 0));
break;
case 'price-desc':
sorted.sort((a, b) => (b.price || 0) - (a.price || 0));
break;
case 'rating-desc':
sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0));
break;
}
displayResults(sorted);
}
// Display results
function displayResults(wines) {
resultsTable.innerHTML = '';
wines.forEach(wine => {
const wineItem = createWineItem(wine);
resultsTable.appendChild(wineItem);
});
resultsTable.style.display = 'block';
noResults.style.display = 'none';
}
// Create wine item element
function createWineItem(wine) {
const item = document.createElement('div');
item.className = 'wine-item';
const header = document.createElement('div');
header.className = 'wine-item-header';
const name = document.createElement('div');
name.className = 'wine-name';
name.textContent = wine.name;
const price = document.createElement('div');
price.className = 'wine-price';
price.textContent = wine.price ? `$${wine.price.toFixed(2)}` : 'N/A';
header.appendChild(name);
header.appendChild(price);
const details = document.createElement('div');
details.className = 'wine-details';
if (wine.vintage) {
details.appendChild(createDetailItem('Vintage:', wine.vintage));
}
if (wine.rating) {
const ratingItem = createDetailItem('Rating:', `${wine.rating} pts`);
ratingItem.querySelector('.wine-detail-item span:last-child').className = 'wine-rating';
details.appendChild(ratingItem);
}
if (wine.source) {
details.appendChild(createDetailItem('Source:', wine.source));
}
if (wine.availability) {
details.appendChild(createDetailItem('Availability:', wine.availability));
}
const actions = document.createElement('div');
actions.className = 'wine-actions';
const historyBtn = document.createElement('button');
historyBtn.className = 'view-history-btn';
historyBtn.textContent = 'VIEW PRICE HISTORY';
historyBtn.addEventListener('click', () => loadWineHistory(wine.name));
actions.appendChild(historyBtn);
if (wine.url) {
const linkBtn = document.createElement('button');
linkBtn.className = 'view-history-btn';
linkBtn.textContent = 'VIEW ON SITE';
linkBtn.style.marginLeft = '10px';
linkBtn.addEventListener('click', () => window.open(wine.url, '_blank', 'noopener,noreferrer'));
actions.appendChild(linkBtn);
}
item.appendChild(header);
item.appendChild(details);
item.appendChild(actions);
return item;
}
// Create detail item
function createDetailItem(label, value) {
const wrapper = document.createElement('div');
const item = document.createElement('div');
item.className = 'wine-detail-item';
const labelSpan = document.createElement('span');
labelSpan.className = 'wine-detail-label';
labelSpan.textContent = label;
const valueSpan = document.createElement('span');
valueSpan.textContent = value;
item.appendChild(labelSpan);
item.appendChild(valueSpan);
wrapper.appendChild(item);
return wrapper;
}
// Display alerts
function displayAlerts(alerts) {
alertsContainer.innerHTML = '';
alerts.forEach(alert => {
const alertItem = document.createElement('div');
alertItem.className = 'alert-item';
const title = document.createElement('div');
title.className = 'alert-title';
title.textContent = `🚨 ${alert.wine}`;
const details = document.createElement('div');
details.className = 'alert-details';
details.innerHTML = `
Price increased from <strong>$${alert.oldPrice.toFixed(2)}</strong>
to <strong>$${alert.newPrice.toFixed(2)}</strong>
(${alert.percentageIncrease.toFixed(1)}% increase)
`;
alertItem.appendChild(title);
alertItem.appendChild(details);
alertsContainer.appendChild(alertItem);
});
alertsSection.style.display = 'block';
}
// Load wine history
async function loadWineHistory(wineName) {
try {
const response = await fetch(`/api/history/wine?name=${encodeURIComponent(wineName)}`, {
credentials: 'same-origin' // Include session cookies
});
// Check for authentication errors
if (response.status === 401) {
window.location.href = '/login';
return;
}
const data = await response.json();
if (data.success && data.history.length > 0) {
displayChart(wineName, data.chartData);
} else {
showError(`No historical price data available for "${wineName}" yet.\n\nHistorical data is recorded each time you search. Try searching again later to build up price history.`);
}
} catch (error) {
console.error('Error loading history:', error);
showError('Failed to load price history.\n\nThis could be due to a connection issue with Google Sheets or a temporary server problem. Please try again in a moment.');
}
}
// Display chart
function displayChart(wineName, chartData) {
chartTitle.textContent = `PRICE HISTORY: ${wineName}`;
chartSection.style.display = 'block';
// Destroy previous chart
if (currentChart) {
currentChart.destroy();
}
const ctx = document.getElementById('priceChart').getContext('2d');
currentChart = new Chart(ctx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [{
label: 'Price ($)',
data: chartData.prices,
borderColor: '#ec4899',
backgroundColor: 'rgba(236, 72, 153, 0.15)',
borderWidth: 3,
tension: 0.4,
fill: true,
pointBackgroundColor: '#ec4899',
pointBorderColor: '#ffffff',
pointBorderWidth: 2,
pointRadius: 7,
pointHoverRadius: 10,
pointHoverBackgroundColor: '#db2777',
pointHoverBorderWidth: 3
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
aspectRatio: 2,
plugins: {
legend: {
display: true,
labels: {
color: '#ffffff',
font: {
family: 'Inter',
size: 15,
weight: '600'
},
padding: 20,
usePointStyle: true,
pointStyle: 'circle'
}
},
tooltip: {
backgroundColor: 'rgba(17, 17, 17, 0.98)',
titleColor: '#ffffff',
bodyColor: '#9ca3af',
borderColor: 'rgba(236, 72, 153, 0.5)',
borderWidth: 2,
titleFont: {
family: 'Inter',
size: 15,
weight: '700'
},
bodyFont: {
family: 'Inter',
size: 14,
weight: '500'
},
padding: 16,
displayColors: false,
cornerRadius: 12,
caretSize: 8
}
},
scales: {
y: {
beginAtZero: false,
ticks: {
color: '#9ca3af',
font: {
family: 'Inter',
size: 13,
weight: '500'
},
padding: 12,
callback: function(value) {
return '$' + value.toFixed(2);
}
},
grid: {
color: 'rgba(255, 255, 255, 0.06)',
borderColor: 'rgba(255, 255, 255, 0.1)',
drawBorder: false
}
},
x: {
ticks: {
color: '#9ca3af',
font: {
family: 'Inter',
size: 13,
weight: '500'
},
padding: 12
},
grid: {
color: 'rgba(255, 255, 255, 0.06)',
borderColor: 'rgba(255, 255, 255, 0.1)',
drawBorder: false
}
}
}
}
});
// Scroll to chart
chartSection.scrollIntoView({ behavior: 'smooth' });
}
// UI state functions
function showLoading() {
loadingIndicator.style.display = 'block';
}
function hideLoading() {
loadingIndicator.style.display = 'none';
}
function showNoResults() {
noResults.style.display = 'block';
resultsTable.style.display = 'none';
}
function hideResults() {
resultsTable.style.display = 'none';
noResults.style.display = 'none';
}
function hideAlerts() {
alertsSection.style.display = 'none';
}
function hideChart() {
chartSection.style.display = 'none';
if (currentChart) {
currentChart.destroy();
currentChart = null;
}
}
function showError(message) {
// Create a more prominent error notification
const errorDiv = document.createElement('div');
errorDiv.className = 'error-notification';
errorDiv.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #1a1a1a 0%, #2d1a2d 100%);
border: 2px solid #ec4899;
border-radius: 16px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(236, 72, 153, 0.3);
z-index: 10000;
color: #ffffff;
font-family: 'Inter', sans-serif;
`;
const icon = document.createElement('div');
icon.style.cssText = 'font-size: 48px; text-align: center; margin-bottom: 20px;';
icon.textContent = '⚠️';
const text = document.createElement('div');
text.style.cssText = 'font-size: 16px; line-height: 1.6; white-space: pre-line; margin-bottom: 20px;';
text.textContent = message;
const closeBtn = document.createElement('button');
closeBtn.textContent = 'OK';
closeBtn.style.cssText = `
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%);
border: none;
border-radius: 8px;
color: white;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
`;
closeBtn.onmouseover = () => {
closeBtn.style.transform = 'translateY(-2px)';
closeBtn.style.boxShadow = '0 4px 12px rgba(236, 72, 153, 0.4)';
};
closeBtn.onmouseout = () => {
closeBtn.style.transform = 'translateY(0)';
closeBtn.style.boxShadow = 'none';
};
closeBtn.onclick = () => {
document.body.removeChild(overlay);
};
// Create overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
z-index: 9999;
`;
overlay.onclick = (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
}
};
errorDiv.appendChild(icon);
errorDiv.appendChild(text);
errorDiv.appendChild(closeBtn);
overlay.appendChild(errorDiv);
document.body.appendChild(overlay);
// Auto-close after 10 seconds if user doesn't click
setTimeout(() => {
if (document.body.contains(overlay)) {
document.body.removeChild(overlay);
}
}, 10000);
}