Filters on advanced search

This commit is contained in:
Jason
2026-05-01 10:05:19 -05:00
parent 1ff3870ae9
commit 9f7c078ccc
2 changed files with 726 additions and 33 deletions
+228
View File
@@ -0,0 +1,228 @@
{
"filterGroups": [
{
"id": "type",
"label": "Type",
"icon": "📋",
"order": 1,
"options": [
{
"id": "storm_door",
"label": "Storm Door",
"value": "Storm Door",
"aliases": ["storm-door", "stormdoor"],
"keywords": ["storm", "door"],
"requiredKeywords": "all",
"associations": {
"productField": "subType.door",
"matchMethod": "exact",
"normalizedValue": "stormdoor"
}
},
{
"id": "storm_window",
"label": "Storm Window",
"value": "Storm Window",
"aliases": ["storm-window", "stormwindow"],
"keywords": ["storm", "window"],
"requiredKeywords": "all",
"associations": {
"productField": "subType.window",
"matchMethod": "exact",
"normalizedValue": "stormwindow"
}
},
{
"id": "primary_window",
"label": "Primary Window",
"value": "Primary Window",
"aliases": ["primary-window", "primarywindow"],
"keywords": ["primary", "window"],
"requiredKeywords": "all",
"associations": {
"productField": "subType.window",
"matchMethod": "exact",
"normalizedValue": "primarywindow"
}
}
],
"logicWithinGroup": "OR",
"autoCheckBehavior": {
"enabled": true,
"requireAllKeywords": true,
"requireSequential": true,
"minSpecificity": 2
}
},
{
"id": "color",
"label": "Color",
"icon": "🎨",
"order": 2,
"options": [
{
"id": "white",
"label": "White",
"value": "White",
"aliases": ["wht"],
"keywords": ["white"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "white"
}
},
{
"id": "bronze",
"label": "Bronze",
"value": "Bronze",
"aliases": ["brz", "brown"],
"keywords": ["bronze"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "bronze"
}
},
{
"id": "black",
"label": "Black",
"value": "Black",
"aliases": ["blk"],
"keywords": ["black"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "black"
}
},
{
"id": "sandstone",
"label": "Sandstone",
"value": "Sandstone",
"aliases": ["sand", "sndst"],
"keywords": ["sandstone"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "sandstone"
}
},
{
"id": "tan",
"label": "Tan",
"value": "Tan",
"aliases": [],
"keywords": ["tan"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "tan"
}
},
{
"id": "mill",
"label": "Mill",
"value": "Mill",
"aliases": ["mill finish"],
"keywords": ["mill"],
"requiredKeywords": "all",
"associations": {
"productField": "colors",
"matchMethod": "array_includes",
"normalizedValue": "mill"
}
}
],
"logicWithinGroup": "OR",
"autoCheckBehavior": {
"enabled": true,
"requireAllKeywords": true,
"requireSequential": false,
"minSpecificity": 1
}
},
{
"id": "material",
"label": "Material",
"icon": "🔧",
"order": 3,
"options": [
{
"id": "aluminum",
"label": "Aluminum",
"value": "Aluminum",
"aliases": ["alum", "aluminium", "al"],
"keywords": ["aluminum"],
"requiredKeywords": "all",
"associations": {
"productField": "materials",
"matchMethod": "array_includes",
"normalizedValue": "aluminum"
}
},
{
"id": "vinyl",
"label": "Vinyl",
"value": "Vinyl",
"aliases": ["vnyl"],
"keywords": ["vinyl"],
"requiredKeywords": "all",
"associations": {
"productField": "materials",
"matchMethod": "array_includes",
"normalizedValue": "vinyl"
}
}
],
"logicWithinGroup": "OR",
"autoCheckBehavior": {
"enabled": true,
"requireAllKeywords": true,
"requireSequential": false,
"minSpecificity": 1
}
}
],
"baseTypes": [
{
"id": "door",
"label": "Door",
"keywords": ["door"],
"impliesOptions": ["storm_door"],
"productField": "baseType",
"normalizedValue": "door"
},
{
"id": "window",
"label": "Window",
"keywords": ["window"],
"impliesOptions": ["storm_window", "primary_window"],
"productField": "baseType",
"normalizedValue": "window"
},
{
"id": "patio_door",
"label": "Patio Door",
"keywords": ["patio", "door"],
"requiredKeywords": "all",
"impliesOptions": [],
"productField": "baseType",
"normalizedValue": "patiodoor"
}
],
"searchBehavior": {
"defaultLogic": "AND",
"filterLogicBetweenGroups": "AND",
"filterLogicWithinGroup": "OR",
"processOrder": ["type", "color", "material"],
"removeMatchedFromSearch": true,
"requireSequentialForMultiWord": true,
"ignoreWords": ["and", "or", "the", "a", "an"]
}
}
+498 -33
View File
@@ -66,6 +66,101 @@
margin-bottom: 20px; margin-bottom: 20px;
color: #666; 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> </style>
</head> </head>
<body> <body>
@@ -103,6 +198,14 @@
style="width: 100%; padding: 15px 20px; font-size: 16px; border: 2px solid #ddd; border-radius: 8px; box-sizing: border-box;" style="width: 100%; padding: 15px 20px; font-size: 16px; border: 2px solid #ddd; border-radius: 8px; box-sizing: border-box;"
onkeyup="handleSearch(event)" 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 <button
onclick="performSearch()" 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;" 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;"
@@ -127,8 +230,239 @@
</div> </div>
<script> <script>
// Product data // Global data
let productData = []; 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 // Load product data on page load
async function loadProductData() { async function loadProductData() {
@@ -174,37 +508,86 @@
return normalizeText(value).includes(term); return normalizeText(value).includes(term);
} }
// Search products with partial matching // Match product against filter option
function searchProducts(searchQuery) { function matchesFilterOption(product, option) {
if (!searchQuery || searchQuery.trim() === '') { const association = option.associations;
return []; 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];
} }
// Split search query into individual terms if (!productValue) return false;
const searchTerms = searchQuery.toLowerCase()
.split(/\s+/)
.filter(term => term.length > 0)
.map(term => normalizeText(term));
console.log('Search terms:', searchTerms); // 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);
}
}
// Filter products that match ALL search terms // Search products with new filter-based logic
function searchProducts(searchQuery, checkedFilters) {
if (!filterConfig) return [];
// Filter products
const results = productData.filter(product => { const results = productData.filter(product => {
// Each product must match ALL search terms // Step 1: Check filters (AND between groups, OR within group)
return searchTerms.every(term => { for (const groupId in checkedFilters) {
// Check if the term matches any of these fields const selectedOptions = checkedFilters[groupId];
return (
matchesTerm(product.productCode, term) || if (selectedOptions.length > 0) {
matchesTerm(product.id, term) || // Check if product matches ANY of the selected options (OR logic)
matchesTerm(product.description, term) || const matchesGroup = selectedOptions.some(option =>
matchesTerm(product.category, term) || matchesFilterOption(product, option)
matchesTerm(product.baseType, term) || );
matchesTerm(product.subType, term) ||
matchesTerm(product.materials, term) || if (!matchesGroup) return false; // AND logic between groups
matchesTerm(product.colors, term) || }
matchesTerm(product.location, term) }
);
}); // 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; return results;
@@ -278,22 +661,79 @@
resultsDiv.innerHTML = html; resultsDiv.innerHTML = html;
} }
// Perform search // Perform search with new filter-based logic
function performSearch() { function performSearch() {
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
const searchQuery = searchInput.value.trim(); let searchQuery = searchInput.value.trim();
let remainingSearch;
if (searchQuery === '') { // 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 = ` document.getElementById('searchResults').innerHTML = `
<div class="no-results"> <div class="no-results">
<p>Please enter a search term</p> <p>Please enter a search term or select filters</p>
</div> </div>
`; `;
return; return;
} }
const results = searchProducts(searchQuery); console.log('Remaining search terms:', remainingSearch);
displayResults(results, searchQuery); 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 // Handle Enter key in search input
@@ -307,6 +747,15 @@
function clearSearch() { function clearSearch() {
document.getElementById('searchInput').value = ''; document.getElementById('searchInput').value = '';
document.getElementById('searchResults').innerHTML = ''; 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(); document.getElementById('searchInput').focus();
} }
@@ -363,6 +812,19 @@
async function init() { async function init() {
await loadUserInfo(); await loadUserInfo();
await loadProductData(); 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 // Check if there's a search query in URL
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@@ -373,6 +835,9 @@
const searchInput = document.getElementById('searchInput'); const searchInput = document.getElementById('searchInput');
searchInput.value = queryParam; searchInput.value = queryParam;
// Auto-check filters that match the search query
autoCheckFiltersFromQuery(queryParam);
// Automatically perform the search // Automatically perform the search
performSearch(); performSearch();
} }