added a button group for quick filtering by productLine

This commit is contained in:
Zach Harding
2026-03-17 11:27:16 -04:00
parent f72d479c1d
commit 7b4e06733f
4 changed files with 65 additions and 43 deletions

View File

@@ -22,7 +22,7 @@
@import 'bootstrap/scss/alert'; @import 'bootstrap/scss/alert';
@import 'bootstrap/scss/badge'; @import 'bootstrap/scss/badge';
// @import 'bootstrap/scss/breadcrumb'; // @import 'bootstrap/scss/breadcrumb';
// @import 'bootstrap/scss/button-group'; @import 'bootstrap/scss/button-group';
@import 'bootstrap/scss/buttons'; @import 'bootstrap/scss/buttons';
@import 'bootstrap/scss/card'; @import 'bootstrap/scss/card';
// @import 'bootstrap/scss/carousel'; // @import 'bootstrap/scss/carousel';

View File

@@ -15,7 +15,7 @@ import BackToTop from "./BackToTop.astro"
</div> </div>
</div> </div>
<div class="col-sm-12 col-md-10 mt-0"> <div class="col-sm-12 col-md-10 mt-0">
<div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-2 w-100 justify-content-start"> <div class="d-flex flex-column gap-1 flex-md-row align-items-center mb-3 w-100 justify-content-start">
<div id="sortBy"></div> <div id="sortBy"></div>
<div id="totalResults"></div> <div id="totalResults"></div>
<div id="activeFilters"></div> <div id="activeFilters"></div>
@@ -48,12 +48,9 @@ import BackToTop from "./BackToTop.astro"
(function () { (function () {
// ── Sort dropdown ───────────────────────────────────────────────────────── // ── Sort dropdown ─────────────────────────────────────────────────────────
// Plain JS toggle — no dependency on Bootstrap's Dropdown JS initialising.
// Uses event delegation so it works after OOB swaps repopulate #sortBy.
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
const sortBy = document.getElementById('sortBy'); const sortBy = document.getElementById('sortBy');
// Toggle the menu when the button is clicked
const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]'); const btn = e.target.closest('#sortBy [data-bs-toggle="dropdown"]');
if (btn) { if (btn) {
e.preventDefault(); e.preventDefault();
@@ -64,7 +61,6 @@ import BackToTop from "./BackToTop.astro"
return; return;
} }
// Handle sort option selection
const opt = e.target.closest('#sortBy .sort-option'); const opt = e.target.closest('#sortBy .sort-option');
if (opt) { if (opt) {
e.preventDefault(); e.preventDefault();
@@ -87,7 +83,6 @@ import BackToTop from "./BackToTop.astro"
return; return;
} }
// Click outside — close any open sort menu
const menu = document.querySelector('#sortBy .dropdown-menu.show'); const menu = document.querySelector('#sortBy .dropdown-menu.show');
if (menu) { if (menu) {
menu.classList.remove('show'); menu.classList.remove('show');
@@ -96,6 +91,25 @@ import BackToTop from "./BackToTop.astro"
} }
}); });
// ── Language toggle ───────────────────────────────────────────────────────
// Buttons live inside #sortBy which is OOB-swapped from the partial, so the
// listener is registered here on document (persistent shell) instead.
document.addEventListener('click', (e) => {
const btn = e.target.closest('.language-btn');
if (!btn) return;
e.preventDefault();
const input = document.getElementById('languageInput');
if (input) input.value = btn.dataset.lang;
const start = document.getElementById('start');
if (start) start.value = '0';
document.getElementById('searchform').dispatchEvent(
new Event('submit', { bubbles: true, cancelable: true })
);
});
// ── Global helpers ──────────────────────────────────────────────────────── // ── Global helpers ────────────────────────────────────────────────────────
window.copyImage = async function(img) { window.copyImage = async function(img) {
try { try {
@@ -201,7 +215,6 @@ import BackToTop from "./BackToTop.astro"
nextBtn.classList.toggle('d-none', next === null); nextBtn.classList.toggle('d-none', next === null);
} }
// ── Trigger infinite scroll sentinel ─────────────────────────────────────
function tryTriggerSentinel() { function tryTriggerSentinel() {
const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]'); const sentinel = cardGrid.querySelector('[hx-trigger="revealed"]');
if (!sentinel) return; if (!sentinel) return;
@@ -212,7 +225,6 @@ import BackToTop from "./BackToTop.astro"
} }
} }
// ── Fire card-modal:swapped so the partial's script can init the chart ────
function initChartAfterSwap(modal) { function initChartAfterSwap(modal) {
const canvas = modal.querySelector('#priceHistoryChart'); const canvas = modal.querySelector('#priceHistoryChart');
if (!canvas) return; if (!canvas) return;
@@ -269,11 +281,9 @@ import BackToTop from "./BackToTop.astro"
if (next) loadCard(next, 'next'); if (next) loadCard(next, 'next');
} }
// ── Nav button clicks ─────────────────────────────────────────────────────
document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev); document.getElementById('modalPrevBtn').addEventListener('click', navigatePrev);
document.getElementById('modalNextBtn').addEventListener('click', navigateNext); document.getElementById('modalNextBtn').addEventListener('click', navigateNext);
// ── Keyboard ──────────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
const modal = document.getElementById('cardModal'); const modal = document.getElementById('cardModal');
if (!modal.classList.contains('show')) return; if (!modal.classList.contains('show')) return;
@@ -281,7 +291,6 @@ import BackToTop from "./BackToTop.astro"
if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); } if (e.key === 'ArrowRight') { e.preventDefault(); navigateNext(); }
}); });
// ── Touch / swipe ─────────────────────────────────────────────────────────
let touchStartX = 0; let touchStartX = 0;
let touchStartY = 0; let touchStartY = 0;
const SWIPE_THRESHOLD = 50; const SWIPE_THRESHOLD = 50;
@@ -299,7 +308,6 @@ import BackToTop from "./BackToTop.astro"
else navigatePrev(); else navigatePrev();
}, { passive: true }); }, { passive: true });
// ── HTMX card-modal opens ─────────────────────────────────────────────────
document.body.addEventListener('htmx:beforeRequest', async (e) => { document.body.addEventListener('htmx:beforeRequest', async (e) => {
if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return; if (e.detail.elt.getAttribute('hx-target') !== '#cardModal') return;
@@ -363,7 +371,6 @@ import BackToTop from "./BackToTop.astro"
} }
}); });
// ── Bootstrap modal events ────────────────────────────────────────────────
const cardModal = document.getElementById('cardModal'); const cardModal = document.getElementById('cardModal');
cardModal.addEventListener('shown.bs.modal', () => { cardModal.addEventListener('shown.bs.modal', () => {
updateNavButtons(cardModal); updateNavButtons(cardModal);

View File

@@ -26,15 +26,21 @@ import { Show } from '@clerk/astro/components'
</script> </script>
<Show when="signed-in"> <Show when="signed-in">
<form class="d-flex ms-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()"> <form class="d-flex ms-2 align-items-center gap-2" role="search" id="searchform" hx-post="/partials/cards" hx-target="#cardGrid" hx-trigger="load, submit" hx-vals='{"start":"0"}' hx-on--after-request="afterUpdate()" hx-on--before-request="beforeSearch()">
<a class="btn btn-secondary btn-lg me-2" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter"><span class="d-block d-md-none filter-icon mt-1"><svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg></span><span class="d-none d-md-block">Filters</span></a> <a class="btn btn-secondary btn-lg" data-bs-toggle="offcanvas" href="#filterBar" role="button" aria-controls="filterBar" aria-label="filter">
<span class="d-block d-md-none filter-icon py-2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path opacity=".4" d="M96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160L96 160z"/><path d="M66.4 147.8C71.4 135.8 83.1 128 96 128L544 128C556.9 128 568.6 135.8 573.6 147.8C578.6 159.8 575.8 173.5 566.7 182.7L384 365.3L384 544C384 556.9 376.2 568.6 364.2 573.6C352.2 578.6 338.5 575.8 329.3 566.7L265.3 502.7C259.3 496.7 255.9 488.6 255.9 480.1L256 365.3L73.4 182.6C64.2 173.5 61.5 159.7 66.4 147.8zM544 160L96 160L283.3 347.3C286.3 350.3 288 354.4 288 358.6L288 480L352 544L352 358.6C352 354.4 353.7 350.3 356.7 347.3L544 160z"/></svg>
</span>
<span class="d-none d-md-block">Filters</span>
</a>
<div class="input-group"> <div class="input-group">
<input type="hidden" name="start" id="start" value="0" /> <input type="hidden" name="start" id="start" value="0" />
<input type="hidden" name="sort" id="sortInput" value="" /> <input type="hidden" name="sort" id="sortInput" value="" />
<input type="hidden" name="language" id="languageInput" value="all" />
<input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." /> <input type="search" name="q" class="form-control form-control-lg" placeholder="Search cards..." />
<button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });"> <button type="submit" class="btn btn-danger btn-lg border border-danger-subtle border-start-0" aria-label="search" value="" onclick="const q = this.closest('form').querySelector('[name=q]').value; dataLayer.push({ event: 'view_search_results', search_term: q });">
<svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg> <svg aria-hidden="true" class="search-button d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M432 272C432 183.6 360.4 112 272 112C183.6 112 112 183.6 112 272C112 360.4 183.6 432 272 432C360.4 432 432 360.4 432 272zM401.1 435.1C365.7 463.2 320.8 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272C480 320.8 463.2 365.7 435.1 401.1L569 535C578.4 544.4 578.4 558.1 569 567.5C559.6 575.9 544.4 575.9 536 567.5L401.1 435.1z"/></svg>
</button> </button>
</div> </div>
</form> </form>
</Show>

View File

@@ -18,11 +18,6 @@ const facetFields:any = {
} }
// ── Allowed sort values ─────────────────────────────────────────────────── // ── Allowed sort values ───────────────────────────────────────────────────
// Maps the client-supplied key to the actual Typesense sort_by string.
// Never pass raw user input directly to sort_by.
// Note: price sorting uses nmMarketPrice — a field you need to denormalize
// onto your card document in your Typesense indexing step (NM market price
// as an integer in cents, e.g. nmMarketPrice: 499 = $4.99).
const sortMap: Record<string, string> = { const sortMap: Record<string, string> = {
'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc', 'releaseDate:desc,number:asc': '_text_match:asc,releaseDate:desc,number:asc',
'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc', 'releaseDate:asc,number:asc': '_text_match:asc,releaseDate:asc,number:asc',
@@ -40,8 +35,16 @@ const start = Number(formData.get('start')?.toString() || '0');
const sortKey = formData.get('sort')?.toString() || ''; const sortKey = formData.get('sort')?.toString() || '';
const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT; const resolvedSort = sortMap[sortKey] ?? DEFAULT_SORT;
// ── Language filter ───────────────────────────────────────────────────────
// Expects a `language` field on your card documents in Typesense.
// Valid values: 'en', 'jp' — anything else (or 'all') means no filter.
const language = formData.get('language')?.toString() || 'all';
const languageFilter = language === 'en' ? " && productLineName:=`Pokemon`"
: language === 'jp' ? " && productLineName:=`Pokemon Japan`"
: '';
const filters = Array.from(formData.entries()) const filters = Array.from(formData.entries())
.filter(([key, value]) => key !== 'q' && key !== 'start' && key !== 'sort') .filter(([key]) => key !== 'q' && key !== 'start' && key !== 'sort' && key !== 'language')
.reduce((acc, [key, value]) => { .reduce((acc, [key, value]) => {
if (!acc[key]) { if (!acc[key]) {
acc[key] = []; acc[key] = [];
@@ -63,14 +66,15 @@ const facetFilter = (facet:string) => {
.filter(([field]) => field !== facet) .filter(([field]) => field !== facet)
.map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`) .map(([field, values]) => `${field}:=[${values.map(v => '`'+v+'`').join(',')}]`)
.join(' && '); .join(' && ');
return `sealed:false${otherFilters ? ` && ${otherFilters}` : ''}`; // Language filter is always included so facet counts stay accurate
return `sealed:false${languageFilter}${otherFilters ? ` && ${otherFilters}` : ''}`;
}; };
// primary search values (for cards) // primary search values (for cards)
let searchArray = [{ let searchArray = [{
collection: 'cards', collection: 'cards',
filter_by: `sealed:false${filterBy ? ` && ${filterBy}` : ''}`, filter_by: `sealed:false${languageFilter}${filterBy ? ` && ${filterBy}` : ''}`,
per_page: 20, per_page: 20,
facet_by: '', facet_by: '',
max_facet_values: 0, max_facet_values: 0,
@@ -135,8 +139,8 @@ const facetNames = (name:string) => {
} }
const facets = searchResults.results.slice(1).map((result: any) => { const facets = searchResults.results.slice(1).map((result: any) => {
const facet = result.facet_counts[0]; const facet = result.facet_counts?.[0];
if (!facet) return facet; if (!facet) return null;
// Sort: checked items first, then alphabetically // Sort: checked items first, then alphabetically
facet.counts = facet.counts.sort((a: any, b: any) => { facet.counts = facet.counts.sort((a: any, b: any) => {
@@ -148,7 +152,7 @@ const facets = searchResults.results.slice(1).map((result: any) => {
}); });
return facet; return facet;
}); }).filter(Boolean);
--- ---
@@ -178,19 +182,24 @@ const facets = searchResults.results.slice(1).map((result: any) => {
</div> </div>
))} ))}
</div> </div>
<div id="sortBy" class="d-flex flex-fill align-items-center me-auto" hx-swap-oob="true"> <div id="sortBy" class="d-flex flex-fill align-items-center me-auto gap-2 small" hx-swap-oob="true">
<div class="btn-group btn-group-sm ms-2 flex-shrink-0" role="group" aria-label="Language filter">
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'all' ? ' active' : ''}`} data-lang="all">All</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'en' ? ' active' : ''}`} data-lang="en">EN</button>
<button type="button" class={`btn btn-outline-secondary language-btn${language === 'jp' ? ' active' : ''}`} data-lang="jp">JP</button>
</div>
<div class="dropdown"> <div class="dropdown">
<button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button> <button class="btn btn-sm btn-dark dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu dropdown-menu-dark"> <ul class="dropdown-menu dropdown-menu-dark">
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:desc,number:asc" data-label="Set: Newest to Oldest">Set: Newest to Oldest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="releaseDate:asc,number:asc" data-label="Set: Oldest to Newest">Set: Oldest to Newest</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:desc" data-label="Price: High to Low">Price: High to Low</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="marketPrice:asc" data-label="Price: Low to High">Price: Low to High</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="number:asc" data-label="Card Number: Ascending">Card Number: Ascending</a></li>
<li><a class="dropdown-item sort-option" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li> <li><a class="dropdown-item sort-option small" href="#" data-sort="number:desc" data-label="Card Number: Descending">Card Number: Descending</a></li>
</ul> </ul>
</div> </div>
<span id="sortLabel" class="ms-1 text-secondary small">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span> <span id="sortLabel" class="text-secondary">{sortKey ? ({"releaseDate:desc,number:asc":"Set: Newest to Oldest","releaseDate:asc,number:asc":"Set: Oldest to Newest","marketPrice:desc":"Price: High to Low","marketPrice:asc":"Price: Low to High","number:asc":"Card Number: Ascending","number:desc":"Card Number: Descending"}[sortKey] ?? '') : ''}</span>
</div> </div>
<div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true"> <div id="totalResults" class="d-flex text-secondary small d-none align-items-center" hx-swap-oob="true">
{totalHits} {totalHits === 1 ? ' result' : ' results'} {totalHits} {totalHits === 1 ? ' result' : ' results'}