feat: expand catalog filters
Quality / test (push) Canceled after 0s

This commit is contained in:
2026-09-10 03:00:20 -04:00
parent b036092775
commit 673701fa7a
12 changed files with 126 additions and 3 deletions
@@ -75,6 +75,9 @@ export function createListingRepository(db) {
text = "", text = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -104,6 +107,18 @@ export function createListingRepository(db) {
conditions.push(eq(inventoryItems.conditionId, conditionId)); conditions.push(eq(inventoryItems.conditionId, conditionId));
} }
if (typeof setName === "string" && setName.trim().length > 0) {
conditions.push(ilike(products.setName, setName));
}
if (typeof eventName === "string" && eventName.trim().length > 0) {
conditions.push(ilike(products.eventName, eventName));
}
if (typeof tag === "string" && tag.trim().length > 0) {
conditions.push(sql`${products.tags}::text ILIKE ${`%${tag}%`}`);
}
if (typeof minPriceMinor === "number") { if (typeof minPriceMinor === "number") {
conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`);
} }
@@ -19,6 +19,9 @@ export function createSearchQuery({
query = "", query = "",
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -70,6 +73,15 @@ export function createSearchQuery({
...(typeof conditionId === "string" && conditionId.trim().length > 0 ...(typeof conditionId === "string" && conditionId.trim().length > 0
? { conditionId: conditionId.trim() } ? { conditionId: conditionId.trim() }
: {}), : {}),
...(typeof setName === "string" && setName.trim().length > 0
? { setName: setName.trim() }
: {}),
...(typeof eventName === "string" && eventName.trim().length > 0
? { eventName: eventName.trim() }
: {}),
...(typeof tag === "string" && tag.trim().length > 0
? { tag: tag.trim().toLowerCase() }
: {}),
...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}), ...(typeof minPriceMinor === "number" ? { minPriceMinor } : {}),
...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}), ...(typeof maxPriceMinor === "number" ? { maxPriceMinor } : {}),
page, page,
@@ -32,6 +32,9 @@ export async function registerPublicSearchRoutes(app, options) {
query: request.query.query, query: request.query.query,
category: request.query.category, category: request.query.category,
conditionId: request.query.conditionId, conditionId: request.query.conditionId,
setName: request.query.setName,
eventName: request.query.eventName,
tag: request.query.tag,
minPriceMinor: coerceNumber( minPriceMinor: coerceNumber(
request.query.minPriceMinor, request.query.minPriceMinor,
"minPriceMinor", "minPriceMinor",
@@ -11,6 +11,9 @@ test("normalizes a public search query and keeps filter values within bounds", (
const query = createSearchQuery({ const query = createSearchQuery({
query: " Charizard ex ", query: " Charizard ex ",
category: "Cards", category: "Cards",
setName: "SV151",
eventName: "Worlds",
tag: "Binder",
minPriceMinor: 1000, minPriceMinor: 1000,
maxPriceMinor: 5000, maxPriceMinor: 5000,
page: 1, page: 1,
@@ -20,6 +23,9 @@ test("normalizes a public search query and keeps filter values within bounds", (
assert.equal(query.text, "charizard ex"); assert.equal(query.text, "charizard ex");
assert.equal(query.category, "Cards"); assert.equal(query.category, "Cards");
assert.equal(query.setName, "SV151");
assert.equal(query.eventName, "Worlds");
assert.equal(query.tag, "binder");
assert.equal(query.minPriceMinor, 1000); assert.equal(query.minPriceMinor, 1000);
assert.equal(query.maxPriceMinor, 5000); assert.equal(query.maxPriceMinor, 5000);
assert.equal(query.page, 1); assert.equal(query.page, 1);
@@ -12,6 +12,9 @@ test("returns public search results with pagination metadata", async (testContex
assert.equal(filters.includeSold, true); assert.equal(filters.includeSold, true);
assert.equal(filters.sort, "price_low"); assert.equal(filters.sort, "price_low");
assert.equal(filters.conditionId, "condition-1"); assert.equal(filters.conditionId, "condition-1");
assert.equal(filters.setName, "SV151");
assert.equal(filters.eventName, "Worlds");
assert.equal(filters.tag, "binder");
return { return {
items: [{ id: "listing-1", status: "CLOSED" }], items: [{ id: "listing-1", status: "CLOSED" }],
pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, pagination: { page: 2, limit: 5, total: 6, totalPages: 2 },
@@ -23,7 +26,7 @@ test("returns public search results with pagination metadata", async (testContex
const response = await app.inject({ const response = await app.inject({
method: "GET", method: "GET",
url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1", url: "/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1&setName=SV151&eventName=Worlds&tag=binder",
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
@@ -0,0 +1,18 @@
.catalog-text-filter {
display: grid;
gap: var(--space-1);
}
.catalog-text-filter span {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.catalog-text-filter input {
min-height: 44px;
border: var(--border-thin) solid var(--color-border);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
background: var(--color-surface);
color: var(--color-text);
}
@@ -0,0 +1,6 @@
import CatalogTextFilter from './CatalogTextFilter.vue';
export default { title: 'Atoms/CatalogTextFilter', component: CatalogTextFilter };
export const Set = { args: { label: 'Set', modelValue: '', placeholder: 'SV151' } };
export const Tag = { args: { label: 'Tag', modelValue: 'binder' } };
@@ -0,0 +1,25 @@
<script setup>
defineProps({
label: { type: String, required: true },
modelValue: { type: String, required: true },
placeholder: { type: String, default: '' },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-text-filter">
<span>{{ label }}</span>
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
</label>
</template>
<style scoped lang="scss">
@use './CatalogTextFilter.scss';
</style>
+13 -1
View File
@@ -6,6 +6,9 @@ export function useCatalogSearch({
initialQuery = "", initialQuery = "",
initialCategory = "", initialCategory = "",
initialConditionId = "", initialConditionId = "",
initialSetName = "",
initialEventName = "",
initialTag = "",
initialMinPriceMinor, initialMinPriceMinor,
initialMaxPriceMinor, initialMaxPriceMinor,
initialSort = "recent", initialSort = "recent",
@@ -14,6 +17,9 @@ export function useCatalogSearch({
const query = ref(initialQuery); const query = ref(initialQuery);
const category = ref(initialCategory); const category = ref(initialCategory);
const conditionId = ref(initialConditionId); const conditionId = ref(initialConditionId);
const setName = ref(initialSetName);
const eventName = ref(initialEventName);
const tag = ref(initialTag);
const minPriceMinor = ref(initialMinPriceMinor); const minPriceMinor = ref(initialMinPriceMinor);
const maxPriceMinor = ref(initialMaxPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor);
const sort = ref(initialSort); const sort = ref(initialSort);
@@ -36,6 +42,9 @@ export function useCatalogSearch({
query: query.value, query: query.value,
category: category.value, category: category.value,
conditionId: conditionId.value, conditionId: conditionId.value,
setName: setName.value,
eventName: eventName.value,
tag: tag.value,
minPriceMinor: minPriceMinor.value, minPriceMinor: minPriceMinor.value,
maxPriceMinor: maxPriceMinor.value, maxPriceMinor: maxPriceMinor.value,
sort: sort.value, sort: sort.value,
@@ -50,7 +59,7 @@ export function useCatalogSearch({
} }
watch( watch(
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort], [query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort],
() => { () => {
if (page.value !== 1) page.value = 1; if (page.value !== 1) page.value = 1;
else load(); else load();
@@ -68,6 +77,9 @@ export function useCatalogSearch({
query, query,
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort, sort,
+17 -1
View File
@@ -9,6 +9,7 @@ import {
} from "@/services/catalog-service.js"; } from "@/services/catalog-service.js";
import ListingCard from "@/components/molecules/ListingCard.vue"; import ListingCard from "@/components/molecules/ListingCard.vue";
import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue"; import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue";
import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -18,6 +19,9 @@ const {
query, query,
category, category,
conditionId, conditionId,
setName,
eventName,
tag,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort, sort,
@@ -33,6 +37,9 @@ const {
typeof route.query.category === "string" ? route.query.category : "", typeof route.query.category === "string" ? route.query.category : "",
initialConditionId: initialConditionId:
typeof route.query.conditionId === "string" ? route.query.conditionId : "", typeof route.query.conditionId === "string" ? route.query.conditionId : "",
initialSetName: typeof route.query.setName === "string" ? route.query.setName : "",
initialEventName: typeof route.query.eventName === "string" ? route.query.eventName : "",
initialTag: typeof route.query.tag === "string" ? route.query.tag : "",
initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined, initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined,
initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined,
initialSort: initialSort:
@@ -57,11 +64,14 @@ onMounted(async () => {
}); });
watch( watch(
[query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], [query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort, page],
([ ([
nextQuery, nextQuery,
nextCategory, nextCategory,
nextCondition, nextCondition,
nextSetName,
nextEventName,
nextTag,
nextMin, nextMin,
nextMax, nextMax,
nextSort, nextSort,
@@ -72,6 +82,9 @@ watch(
...(nextQuery ? { q: nextQuery } : {}), ...(nextQuery ? { q: nextQuery } : {}),
...(nextCategory ? { category: nextCategory } : {}), ...(nextCategory ? { category: nextCategory } : {}),
...(nextCondition ? { conditionId: nextCondition } : {}), ...(nextCondition ? { conditionId: nextCondition } : {}),
...(nextSetName ? { setName: nextSetName } : {}),
...(nextEventName ? { eventName: nextEventName } : {}),
...(nextTag ? { tag: nextTag } : {}),
...(typeof nextMin === "number" ? { min: String(nextMin) } : {}), ...(typeof nextMin === "number" ? { min: String(nextMin) } : {}),
...(typeof nextMax === "number" ? { max: String(nextMax) } : {}), ...(typeof nextMax === "number" ? { max: String(nextMax) } : {}),
...(nextSort !== "recent" ? { sort: nextSort } : {}), ...(nextSort !== "recent" ? { sort: nextSort } : {}),
@@ -99,6 +112,9 @@ watch(
/> />
</label> </label>
<div class="catalog-filters"> <div class="catalog-filters">
<CatalogTextFilter v-model="setName" label="Set" placeholder="SV151" />
<CatalogTextFilter v-model="eventName" label="Event" placeholder="Worlds" />
<CatalogTextFilter v-model="tag" label="Tag" placeholder="binder" />
<label <label
>Category >Category
<select v-model="category"> <select v-model="category">
+6
View File
@@ -4,6 +4,9 @@ export async function searchCatalog({
query = "", query = "",
category = "", category = "",
conditionId = "", conditionId = "",
setName = "",
eventName = "",
tag = "",
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort = "recent", sort = "recent",
@@ -18,6 +21,9 @@ export async function searchCatalog({
if (sort !== "recent") params.set("sort", sort); if (sort !== "recent") params.set("sort", sort);
if (category) params.set("category", category); if (category) params.set("category", category);
if (conditionId) params.set("conditionId", conditionId); if (conditionId) params.set("conditionId", conditionId);
if (setName) params.set("setName", setName);
if (eventName) params.set("eventName", eventName);
if (tag) params.set("tag", tag);
if (typeof minPriceMinor === "number") if (typeof minPriceMinor === "number")
params.set("minPriceMinor", String(minPriceMinor)); params.set("minPriceMinor", String(minPriceMinor));
if (typeof maxPriceMinor === "number") if (typeof maxPriceMinor === "number")
+1
View File
@@ -164,6 +164,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
- Authenticated E2E coverage now verifies both the core catalog workflow and the Settings, Saved Views, and export-controls workflow. - Authenticated E2E coverage now verifies both the core catalog workflow and the Settings, Saved Views, and export-controls workflow.
- Production dependency audit currently reports zero vulnerabilities with `npm audit --omit=dev`. - Production dependency audit currently reports zero vulnerabilities with `npm audit --omit=dev`.
- Public catalog sorting now supports recently added, recently updated, name A-Z, name Z-A, price low-to-high, and price high-to-low through a URL-synchronized atomic sort control. Broader inventory filter parity and market-value sorting remain. - Public catalog sorting now supports recently added, recently updated, name A-Z, name Z-A, price low-to-high, and price high-to-low through a URL-synchronized atomic sort control. Broader inventory filter parity and market-value sorting remain.
- Public catalog filtering now supports URL-synchronized set, event, and tag text filters through a reusable atomic text-filter control and the shared search API.
## Local Commands ## Local Commands