Files
CGW-Quote-Builder/app/templates/advanced_search.html
T
2026-05-01 10:05:19 -05:00

854 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>Advanced Search - Product Finder</title>
<link rel="stylesheet" href="{{ url_for('products.serve_css', filename='styles.css') }}">
<style>
.search-container {
padding: 30px 40px;
}
.search-header {
text-align: center;
margin-bottom: 30px;
}
.search-input-container {
max-width: 800px;
margin: 0 auto;
text-align: center;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.product-card {
background: white;
border: 2px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
cursor: pointer;
transition: all 0.2s;
}
.product-card:hover {
border-color: #6a4c93;
box-shadow: 0 4px 12px rgba(106, 76, 147, 0.15);
transform: translateY(-2px);
}
.product-card h3 {
color: #333;
font-size: 1.1em;
margin-bottom: 10px;
}
.no-results {
text-align: center;
padding: 40px;
color: #666;
}
.search-stats {
text-align: center;
padding: 15px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 20px;
color: #666;
}
/* Filter Sections */
.filters-container {
max-width: 800px;
margin: 20px auto;
}
.filter-section {
margin-bottom: 10px;
}
.filter-toggle {
width: 100%;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 12px 16px;
font-size: 14px;
color: #495057;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.2s ease;
text-align: left;
}
.filter-toggle:hover {
background-color: #e9ecef;
border-color: #ced4da;
}
.filter-icon {
font-size: 18px;
}
.filter-label {
flex: 1;
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.filter-title {
font-weight: 600;
}
.filter-values {
color: #6a4c93;
font-weight: 500;
}
.filter-arrow {
font-size: 12px;
transition: transform 0.2s ease;
}
.filter-content {
margin-top: 5px;
padding: 16px;
background-color: #fff;
border: 1px solid #dee2e6;
border-radius: 6px;
font-size: 14px;
}
.filter-options {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 10px;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.filter-checkbox:hover {
background-color: #f8f9fa;
}
.filter-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
.filter-checkbox span {
user-select: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Advanced Search</h1>
</div>
<div class="user-info-bar" id="userInfoBar">
<span id="userLocationInfo"></span>
<a href="{{ url_for('auth.logout') }}" class="logout-btn" title="Logout"
onclick="return confirm('Are you sure you want to log out?');"
style="background: none; border: none; cursor: pointer; font-size: 16px; text-decoration: none; display: inline-block; padding: 8px 12px;">🚪</a>
</div>
<div class="breadcrumb">
Start Advanced Search
</div>
<div id="content">
<div class="search-container">
<div class="search-header">
<h2 style="margin-bottom: 15px;">🔍 Search Products</h2>
<p style="color: #666; margin-bottom: 20px;">
Search by product code, description, color, material, or any combination.
Try: "c1800", "storm door white", "bronze aluminum"
</p>
</div>
<div class="search-input-container">
<input
type="text"
id="searchInput"
placeholder="Enter search terms..."
style="width: 100%; padding: 15px 20px; font-size: 16px; border: 2px solid #ddd; border-radius: 8px; box-sizing: border-box;"
onkeyup="handleSearch(event)"
>
<!-- Hidden field stores the cleaned search text after filter keywords are removed -->
<input type="hidden" id="remainingSearchText" value="">
<!-- Filter Sections (dynamically generated from filter_config.json) -->
<div id="filtersContainer" class="filters-container" style="margin-top: 20px;">
<!-- Filters will be generated here by JavaScript -->
</div>
<button
onclick="performSearch()"
style="margin-top: 15px; padding: 15px 30px; font-size: 16px; background-color: #6a4c93; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;"
>
Search
</button>
<button
onclick="clearSearch()"
style="margin-top: 15px; margin-left: 10px; padding: 15px 30px; font-size: 16px; background-color: #ccc; color: #333; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;"
>
Clear
</button>
</div>
<div id="searchResults" style="margin-top: 30px;"></div>
<div class="result-actions" style="margin-top: 30px;">
<button class="back-button" onclick="goToFinder()">🏠 Back to Product Finder</button>
</div>
</div>
</div>
</div>
<script>
// Global data
let productData = [];
let filterConfig = null;
let searchFieldDirty = false; // Track if user has manually edited search after initial load
// Load filter configuration
async function loadFilterConfig() {
try {
const response = await fetch('/quiz/data/filter_config.json');
filterConfig = await response.json();
console.log('Loaded filter config:', filterConfig);
return filterConfig;
} catch (error) {
console.error('Error loading filter config:', error);
return null;
}
}
// Generate filter UI from configuration
function generateFiltersUI() {
if (!filterConfig) return;
const container = document.getElementById('filtersContainer');
let html = '';
// Sort filter groups by order
const sortedGroups = [...filterConfig.filterGroups].sort((a, b) => a.order - b.order);
sortedGroups.forEach(group => {
const filterId = `${group.id}Filter`;
const valuesId = `${group.id}FilterValues`;
html += `
<div class="filter-section">
<button class="filter-toggle" onclick="toggleFilter('${filterId}')" type="button">
<span class="filter-icon">${group.icon}</span>
<span class="filter-label">
<span class="filter-title">${group.label}:</span>
<span class="filter-values" id="${valuesId}">None selected</span>
</span>
<span class="filter-arrow" id="${filterId}-arrow">▼</span>
</button>
<div class="filter-content" id="${filterId}" style="display: none;">
<div class="filter-options">
`;
group.options.forEach(option => {
html += `
<label class="filter-checkbox">
<input type="checkbox"
name="${group.id}"
value="${option.value}"
data-option-id="${option.id}">
<span>${option.label}</span>
</label>
`;
});
html += `
</div>
</div>
</div>
`;
});
container.innerHTML = html;
}
// Toggle filter visibility
function toggleFilter(filterId) {
const filterContent = document.getElementById(filterId);
const arrow = document.getElementById(filterId + '-arrow');
if (filterContent.style.display === 'none') {
filterContent.style.display = 'block';
arrow.textContent = '▲';
} else {
filterContent.style.display = 'none';
arrow.textContent = '▼';
}
}
// Get all checked filter values organized by group
function getCheckedFilters() {
const filters = {};
filterConfig.filterGroups.forEach(group => {
filters[group.id] = [];
document.querySelectorAll(`input[name="${group.id}"]:checked`).forEach(checkbox => {
const optionId = checkbox.dataset.optionId;
const option = group.options.find(opt => opt.id === optionId);
if (option) {
filters[group.id].push(option);
}
});
});
return filters;
}
// Update filter display to show selected values
function updateFilterDisplays() {
if (!filterConfig) return;
filterConfig.filterGroups.forEach(group => {
const valuesId = `${group.id}FilterValues`;
const display = document.getElementById(valuesId);
if (!display) return;
const checked = document.querySelectorAll(`input[name="${group.id}"]:checked`);
if (checked.length > 0) {
const labels = Array.from(checked).map(cb => cb.value);
display.textContent = labels.join(', ');
display.style.color = '#6a4c93';
} else {
display.textContent = 'None selected';
display.style.color = '#999';
}
});
}
// Add event listeners to all checkboxes
function initializeFilterListeners() {
document.querySelectorAll('.filter-checkbox input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', updateFilterDisplays);
});
}
// Check if keywords appear sequentially in search string
function checkSequentialMatch(searchString, keywords) {
if (keywords.length === 0) return false;
if (keywords.length === 1) return searchString.includes(keywords[0]);
let lastIndex = -1;
for (const keyword of keywords) {
const index = searchString.indexOf(keyword, lastIndex + 1);
if (index === -1 || index <= lastIndex) {
return false;
}
lastIndex = index;
}
return true;
}
// Remove only the specific keywords from search string, not text between them
function removeKeywordsFromSearch(searchString, keywords) {
if (keywords.length === 0) return searchString;
let result = searchString;
// Remove each keyword individually
keywords.forEach(keyword => {
// Use word boundary to remove only complete words
const regex = new RegExp(`\\b${keyword}\\b`, 'gi');
result = result.replace(regex, '').trim();
});
// Clean up multiple spaces
result = result.replace(/\s+/g, ' ').trim();
return result;
}
// Auto-check filters based on search query using sequential matching
function autoCheckFiltersFromQuery(searchQuery) {
if (!searchQuery || !filterConfig) return;
let remainingSearch = searchQuery.toLowerCase().trim();
// Remove ignored words
const ignoreWords = filterConfig.searchBehavior.ignoreWords || [];
ignoreWords.forEach(word => {
const regex = new RegExp(`\\b${word}\\b`, 'gi');
remainingSearch = remainingSearch.replace(regex, ' ').replace(/\s+/g, ' ').trim();
});
console.log('Processing search:', remainingSearch);
// Process filter groups in configured order
const processOrder = filterConfig.searchBehavior.processOrder || [];
processOrder.forEach(groupId => {
const group = filterConfig.filterGroups.find(g => g.id === groupId);
if (!group || !group.autoCheckBehavior.enabled) return;
group.options.forEach(option => {
// Normalize keywords
const normalizedKeywords = option.keywords.map(kw => kw.toLowerCase());
// Check if minimum specificity is met
const minSpec = group.autoCheckBehavior.minSpecificity || 1;
if (normalizedKeywords.length < minSpec) return;
// Check if all keywords are present
const allPresent = normalizedKeywords.every(kw =>
remainingSearch.includes(kw)
);
if (!allPresent) return;
// If sequential is required, check that
if (group.autoCheckBehavior.requireSequential && normalizedKeywords.length > 1) {
if (!checkSequentialMatch(remainingSearch, normalizedKeywords)) {
return;
}
}
// Check the checkbox
const checkbox = document.querySelector(
`input[name="${group.id}"][data-option-id="${option.id}"]`
);
if (checkbox) {
checkbox.checked = true;
console.log(`Auto-checked: ${option.label}`);
// Remove keywords from search if configured
if (filterConfig.searchBehavior.removeMatchedFromSearch) {
remainingSearch = removeKeywordsFromSearch(remainingSearch, normalizedKeywords);
console.log('Remaining search:', remainingSearch);
}
}
});
});
// Update displays after auto-checking
updateFilterDisplays();
console.log('Final remaining search:', remainingSearch);
// Store the cleaned search text in hidden field
document.getElementById('remainingSearchText').value = remainingSearch;
}
// Load product data on page load
async function loadProductData() {
try {
const response = await fetch('/quiz/data/products.json');
productData = await response.json();
console.log('Loaded', productData.length, 'products');
} catch (error) {
console.error('Error loading product data:', error);
document.getElementById('searchResults').innerHTML = `
<div class="no-results">
<p style="color: #d32f2f;">⚠️ Error loading product data. Please refresh the page.</p>
</div>
`;
}
}
// Normalize text for matching (remove spaces, hyphens, special chars, lowercase)
function normalizeText(text) {
if (!text) return '';
return text.toString().toLowerCase()
.replace(/[-\s_]/g, '') // Remove hyphens, spaces, underscores
.replace(/[^a-z0-9]/g, ''); // Remove other special characters
}
// Check if a value matches a search term (partial match)
function matchesTerm(value, term) {
if (!value) return false;
// For arrays (like colors, materials)
if (Array.isArray(value)) {
return value.some(item => normalizeText(item).includes(term));
}
// For objects (like subType)
if (typeof value === 'object' && value !== null) {
return Object.values(value).some(v =>
v && normalizeText(v).includes(term)
);
}
// For strings and numbers
return normalizeText(value).includes(term);
}
// Match product against filter option
function matchesFilterOption(product, option) {
const association = option.associations;
const field = association.productField;
const normalizedValue = association.normalizedValue;
const matchMethod = association.matchMethod;
// Get the product field value
let productValue;
if (field.includes('.')) {
const parts = field.split('.');
productValue = product[parts[0]]?.[parts[1]];
} else {
productValue = product[field];
}
if (!productValue) return false;
// Apply match method
switch (matchMethod) {
case 'exact':
return normalizeText(productValue) === normalizedValue;
case 'array_includes':
if (Array.isArray(productValue)) {
return productValue.some(v => normalizeText(v) === normalizedValue);
}
return normalizeText(productValue) === normalizedValue;
case 'contains':
return normalizeText(productValue).includes(normalizedValue);
default:
return normalizeText(productValue).includes(normalizedValue);
}
}
// Search products with new filter-based logic
function searchProducts(searchQuery, checkedFilters) {
if (!filterConfig) return [];
// Filter products
const results = productData.filter(product => {
// Step 1: Check filters (AND between groups, OR within group)
for (const groupId in checkedFilters) {
const selectedOptions = checkedFilters[groupId];
if (selectedOptions.length > 0) {
// Check if product matches ANY of the selected options (OR logic)
const matchesGroup = selectedOptions.some(option =>
matchesFilterOption(product, option)
);
if (!matchesGroup) return false; // AND logic between groups
}
}
// Step 2: Check search terms (AND logic)
if (searchQuery && searchQuery.trim()) {
const searchTerms = searchQuery.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 0)
.map(term => normalizeText(term));
// Product must match ALL search terms
const matchesSearch = searchTerms.every(term => {
return (
matchesTerm(product.productCode, term) ||
matchesTerm(product.id, term) ||
matchesTerm(product.description, term) ||
matchesTerm(product.category, term) ||
matchesTerm(product.baseType, term) ||
matchesTerm(product.subType, term) ||
matchesTerm(product.materials, term) ||
matchesTerm(product.colors, term) ||
matchesTerm(product.location, term)
);
});
if (!matchesSearch) return false;
}
return true;
});
return results;
}
// Display search results
function displayResults(results, searchQuery) {
const resultsDiv = document.getElementById('searchResults');
if (results.length === 0) {
resultsDiv.innerHTML = `
<div class="no-results">
<h3 style="color: #333; margin-bottom: 10px;">No products found</h3>
<p>No products match "${searchQuery}"</p>
<p style="margin-top: 15px; font-size: 0.9em;">Try:</p>
<ul style="text-align: left; max-width: 400px; margin: 10px auto;">
<li>Fewer or more general terms</li>
<li>Different spellings (c1800, c-1800, c 1800)</li>
<li>Product categories (storm door, window, slider)</li>
<li>Materials (aluminum, vinyl) or colors (white, bronze)</li>
</ul>
</div>
`;
return;
}
let html = `
<div class="search-stats">
Found <strong>${results.length}</strong> product${results.length !== 1 ? 's' : ''} matching "<strong>${searchQuery}</strong>"
</div>
<div class="products-grid">
`;
results.forEach(product => {
const prodCode = product.productCode || product.id;
const desc = product.description || 'No description';
const materials = product.materials && product.materials.length > 0
? product.materials.join(', ')
: 'N/A';
const colors = product.colors && product.colors.length > 0
? product.colors.join(', ')
: 'N/A';
const location = product.location || 'N/A';
const discontinued = product.discontinued ? ' <span style="color: #d32f2f;">(Discontinued)</span>' : '';
// Get subtype string
let subtypeStr = '';
if (product.subType) {
const doorType = product.subType.door;
const windowType = product.subType.window;
if (doorType) subtypeStr = doorType;
else if (windowType) subtypeStr = windowType;
}
html += `
<div class="product-card" onclick="viewProductDetail('${prodCode}')">
<h3>${desc}${discontinued}</h3>
<p style="color: #999; font-size: 0.95em; margin-top: 5px;"><strong>${prodCode}</strong></p>
<div style="font-size: 0.9em; color: #666; margin-top: 10px;">
${product.baseType ? `<strong>Type:</strong> ${product.baseType}<br>` : ''}
${subtypeStr ? `<strong>Subtype:</strong> ${subtypeStr}<br>` : ''}
<strong>Materials:</strong> ${materials}<br>
<strong>Colors:</strong> ${colors}<br>
<strong>Location:</strong> ${location}
</div>
</div>
`;
});
html += '</div>';
resultsDiv.innerHTML = html;
}
// Perform search with new filter-based logic
function performSearch() {
const searchInput = document.getElementById('searchInput');
let searchQuery = searchInput.value.trim();
let remainingSearch;
// If search field has been manually modified, re-process everything
if (searchFieldDirty) {
console.log('=== Search field modified, re-processing ===');
// Clear all filters
document.querySelectorAll('.filter-option input[type="checkbox"]').forEach(cb => {
cb.checked = false;
});
// Re-run auto-check from the new search text
if (searchQuery) {
autoCheckFiltersFromQuery(searchQuery);
}
// Use the cleaned search from hidden field
remainingSearch = document.getElementById('remainingSearchText').value.trim();
// Reset the dirty flag
searchFieldDirty = false;
} else {
// Use the pre-calculated remaining search from hidden field
remainingSearch = document.getElementById('remainingSearchText').value.trim().toLowerCase();
console.log('=== Using stored remaining search ===');
}
// Get checked filters
const checkedFilters = getCheckedFilters();
// Check if we have any search criteria
const hasRemainingSearch = remainingSearch && remainingSearch.trim().length > 0;
const hasFilters = Object.values(checkedFilters).some(arr => arr.length > 0);
if (!hasRemainingSearch && !hasFilters) {
document.getElementById('searchResults').innerHTML = `
<div class="no-results">
<p>Please enter a search term or select filters</p>
</div>
`;
return;
}
console.log('Remaining search terms:', remainingSearch);
console.log('Checked filters:', checkedFilters);
// Perform search
const results = searchProducts(remainingSearch, checkedFilters);
// Build display query string
const filterParts = [];
filterConfig.filterGroups.forEach(group => {
const checkedOptions = checkedFilters[group.id] || [];
if (checkedOptions.length > 0) {
const labels = checkedOptions.map(opt => opt.label).join(', ');
filterParts.push(`${group.label}: ${labels}`);
}
});
let displayQuery = remainingSearch || '';
if (filterParts.length > 0) {
if (displayQuery) {
displayQuery += ' | ' + filterParts.join(' | ');
} else {
displayQuery = filterParts.join(' | ');
}
}
displayResults(results, displayQuery || 'all filters');
}
// Handle Enter key in search input
function handleSearch(event) {
if (event.key === 'Enter') {
performSearch();
}
}
// Clear search
function clearSearch() {
document.getElementById('searchInput').value = '';
document.getElementById('searchResults').innerHTML = '';
// Uncheck all filters
document.querySelectorAll('.filter-checkbox input[type="checkbox"]').forEach(checkbox => {
checkbox.checked = false;
});
// Update filter displays
updateFilterDisplays();
document.getElementById('searchInput').focus();
}
// View product detail (redirect to product finder with product code)
function viewProductDetail(productCode) {
window.location.href = `/quiz/?p=${productCode}`;
}
// Load user session info
async function loadUserInfo() {
try {
const response = await fetch('/api/session');
const data = await response.json();
if (data.status === 'success') {
const locationNames = {
'LINDS': 'Lindsborg',
'IOLA': 'Iola',
'KC': 'Kansas City',
'BMD': 'BMD'
};
const currentLoc = locationNames[data.currentLocation] || data.currentLocation;
const canChangeLocation = data.accessibleLocations && data.accessibleLocations.length > 1;
let locationDisplay;
if (canChangeLocation) {
locationDisplay = `<a href="{{ url_for('auth.select_location_page') }}" style="color: #6a4c93; text-decoration: none; font-weight: 500; cursor: pointer;"
onmouseover="this.style.textDecoration='underline'"
onmouseout="this.style.textDecoration='none'"
title="Click to change location">📍 ${currentLoc}</a>`;
} else {
locationDisplay = `📍 ${currentLoc}`;
}
document.getElementById('userLocationInfo').innerHTML =
`👤 ${data.username} | ${locationDisplay}`;
}
} catch (error) {
console.error('Error loading user info:', error);
}
}
// Navigation functions
function goBack() {
window.history.back();
}
function goToFinder() {
window.location.href = '{{ url_for("products.quiz_index") }}';
}
// Initialize page
async function init() {
await loadUserInfo();
await loadProductData();
await loadFilterConfig();
// Generate filter UI from config
if (filterConfig) {
generateFiltersUI();
initializeFilterListeners();
}
// Mark search field as dirty when user manually types
document.getElementById('searchInput').addEventListener('input', () => {
searchFieldDirty = true;
console.log('Search field marked as dirty');
});
// Check if there's a search query in URL
const urlParams = new URLSearchParams(window.location.search);
const queryParam = urlParams.get('q');
if (queryParam) {
// Populate search input with the query
const searchInput = document.getElementById('searchInput');
searchInput.value = queryParam;
// Auto-check filters that match the search query
autoCheckFiltersFromQuery(queryParam);
// Automatically perform the search
performSearch();
}
// Focus on search input
document.getElementById('searchInput').focus();
}
// Load when page loads
init();
</script>
</body>
</html>