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

This commit is contained in:
2026-09-10 03:02:23 -04:00
parent 673701fa7a
commit c9ad3a11af
12 changed files with 114 additions and 7 deletions
@@ -78,6 +78,8 @@ export function createListingRepository(db) {
setName, setName,
eventName, eventName,
tag, tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -89,16 +91,26 @@ export function createListingRepository(db) {
const conditions = [ const conditions = [
inArray( inArray(
listings.status, listings.status,
includeSold status === "SOLD"
? ["CLOSED"]
: status === "PENDING"
? ["PENDING"]
: status === "AVAILABLE"
? ["AVAILABLE"]
: includeSold
? ["AVAILABLE", "PENDING", "CLOSED"] ? ["AVAILABLE", "PENDING", "CLOSED"]
: ["AVAILABLE", "PENDING"], : ["AVAILABLE", "PENDING"],
), ),
inArray( inArray(
inventoryItems.status, inventoryItems.status,
includeSold ? ["ACTIVE", "SOLD"] : ["ACTIVE"], status === "SOLD" ? ["SOLD"] : includeSold || status === "ALL" ? ["ACTIVE", "SOLD"] : ["ACTIVE"],
), ),
]; ];
if (newlyAdded) {
conditions.push(sql`${inventoryItems.createdAt} >= NOW() - INTERVAL '30 days'`);
}
if (typeof category === "string" && category.trim().length > 0) { if (typeof category === "string" && category.trim().length > 0) {
conditions.push(eq(products.categoryId, category)); conditions.push(eq(products.categoryId, category));
} }
@@ -22,6 +22,8 @@ export function createSearchQuery({
setName, setName,
eventName, eventName,
tag, tag,
status,
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
page = 1, page = 1,
@@ -39,6 +41,10 @@ export function createSearchQuery({
throw new Error("limit must be between 1 and 100"); throw new Error("limit must be between 1 and 100");
} }
if (status && !["AVAILABLE", "PENDING", "SOLD", "ALL"].includes(status)) {
throw new Error("status must be AVAILABLE, PENDING, SOLD, or ALL");
}
if ( if (
typeof minPriceMinor !== "undefined" && typeof minPriceMinor !== "undefined" &&
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0) (!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
@@ -87,6 +93,8 @@ export function createSearchQuery({
page, page,
limit, limit,
includeSold, includeSold,
...(status ? { status } : {}),
...(newlyAdded ? { newlyAdded: true } : {}),
...(sort !== "recent" ? { sort } : {}), ...(sort !== "recent" ? { sort } : {}),
}; };
} }
@@ -35,6 +35,8 @@ export async function registerPublicSearchRoutes(app, options) {
setName: request.query.setName, setName: request.query.setName,
eventName: request.query.eventName, eventName: request.query.eventName,
tag: request.query.tag, tag: request.query.tag,
status: request.query.status,
newlyAdded: request.query.newlyAdded === "true" || request.query.newlyAdded === true,
minPriceMinor: coerceNumber( minPriceMinor: coerceNumber(
request.query.minPriceMinor, request.query.minPriceMinor,
"minPriceMinor", "minPriceMinor",
@@ -72,6 +72,13 @@ test("accepts only the supported public search sort values", () => {
); );
}); });
test("accepts public status and newly-added filters", () => {
const query = createSearchQuery({ status: "SOLD", newlyAdded: true });
assert.equal(query.status, "SOLD");
assert.equal(query.newlyAdded, true);
assert.throws(() => createSearchQuery({ status: "UNKNOWN" }), /status must be/);
});
test("normalizes whitespace and punctuation in free-text search terms", () => { test("normalizes whitespace and punctuation in free-text search terms", () => {
assert.equal( assert.equal(
normalizeSearchQuery(" Pikachu #199 / holo "), normalizeSearchQuery(" Pikachu #199 / holo "),
@@ -15,6 +15,8 @@ test("returns public search results with pagination metadata", async (testContex
assert.equal(filters.setName, "SV151"); assert.equal(filters.setName, "SV151");
assert.equal(filters.eventName, "Worlds"); assert.equal(filters.eventName, "Worlds");
assert.equal(filters.tag, "binder"); assert.equal(filters.tag, "binder");
assert.equal(filters.status, "SOLD");
assert.equal(filters.newlyAdded, true);
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 },
@@ -26,7 +28,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&setName=SV151&eventName=Worlds&tag=binder", 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&status=SOLD&newlyAdded=true",
}); });
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
@@ -0,0 +1,14 @@
.catalog-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: 44px;
color: var(--color-text);
font-size: var(--text-sm);
}
.catalog-toggle input {
width: 1.1rem;
height: 1.1rem;
accent-color: var(--color-interactive);
}
@@ -0,0 +1,6 @@
import CatalogToggle from './CatalogToggle.vue';
export default { title: 'Atoms/CatalogToggle', component: CatalogToggle };
export const NewlyAdded = { args: { label: 'Newly added', modelValue: false } };
export const IncludeSold = { args: { label: 'Include sold', modelValue: true } };
@@ -0,0 +1,23 @@
<script setup>
defineProps({
modelValue: { type: Boolean, required: true },
label: { type: String, required: true },
});
defineEmits(['update:modelValue']);
</script>
<template>
<label class="catalog-toggle">
<input
:checked="modelValue"
type="checkbox"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<span>{{ label }}</span>
</label>
</template>
<style scoped lang="scss">
@use './CatalogToggle.scss';
</style>
+9 -1
View File
@@ -9,6 +9,8 @@ export function useCatalogSearch({
initialSetName = "", initialSetName = "",
initialEventName = "", initialEventName = "",
initialTag = "", initialTag = "",
initialStatus = "",
initialNewlyAdded = false,
initialMinPriceMinor, initialMinPriceMinor,
initialMaxPriceMinor, initialMaxPriceMinor,
initialSort = "recent", initialSort = "recent",
@@ -20,6 +22,8 @@ export function useCatalogSearch({
const setName = ref(initialSetName); const setName = ref(initialSetName);
const eventName = ref(initialEventName); const eventName = ref(initialEventName);
const tag = ref(initialTag); const tag = ref(initialTag);
const status = ref(initialStatus);
const newlyAdded = ref(initialNewlyAdded);
const minPriceMinor = ref(initialMinPriceMinor); const minPriceMinor = ref(initialMinPriceMinor);
const maxPriceMinor = ref(initialMaxPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor);
const sort = ref(initialSort); const sort = ref(initialSort);
@@ -45,6 +49,8 @@ export function useCatalogSearch({
setName: setName.value, setName: setName.value,
eventName: eventName.value, eventName: eventName.value,
tag: tag.value, tag: tag.value,
status: status.value,
newlyAdded: newlyAdded.value,
minPriceMinor: minPriceMinor.value, minPriceMinor: minPriceMinor.value,
maxPriceMinor: maxPriceMinor.value, maxPriceMinor: maxPriceMinor.value,
sort: sort.value, sort: sort.value,
@@ -59,7 +65,7 @@ export function useCatalogSearch({
} }
watch( watch(
[query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort], [query, category, conditionId, setName, eventName, tag, status, newlyAdded, minPriceMinor, maxPriceMinor, sort],
() => { () => {
if (page.value !== 1) page.value = 1; if (page.value !== 1) page.value = 1;
else load(); else load();
@@ -80,6 +86,8 @@ export function useCatalogSearch({
setName, setName,
eventName, eventName,
tag, tag,
status,
newlyAdded,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort, sort,
+21 -1
View File
@@ -10,6 +10,7 @@ import {
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"; import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue";
import CatalogToggle from "@/components/atoms/CatalogToggle.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -22,6 +23,8 @@ const {
setName, setName,
eventName, eventName,
tag, tag,
status,
newlyAdded,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort, sort,
@@ -40,6 +43,8 @@ const {
initialSetName: typeof route.query.setName === "string" ? route.query.setName : "", initialSetName: typeof route.query.setName === "string" ? route.query.setName : "",
initialEventName: typeof route.query.eventName === "string" ? route.query.eventName : "", initialEventName: typeof route.query.eventName === "string" ? route.query.eventName : "",
initialTag: typeof route.query.tag === "string" ? route.query.tag : "", initialTag: typeof route.query.tag === "string" ? route.query.tag : "",
initialStatus: typeof route.query.status === "string" ? route.query.status : "",
initialNewlyAdded: route.query.newlyAdded === "true",
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:
@@ -64,7 +69,7 @@ onMounted(async () => {
}); });
watch( watch(
[query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort, page], [query, category, conditionId, setName, eventName, tag, status, newlyAdded, minPriceMinor, maxPriceMinor, sort, page],
([ ([
nextQuery, nextQuery,
nextCategory, nextCategory,
@@ -72,6 +77,8 @@ watch(
nextSetName, nextSetName,
nextEventName, nextEventName,
nextTag, nextTag,
nextStatus,
nextNewlyAdded,
nextMin, nextMin,
nextMax, nextMax,
nextSort, nextSort,
@@ -85,6 +92,8 @@ watch(
...(nextSetName ? { setName: nextSetName } : {}), ...(nextSetName ? { setName: nextSetName } : {}),
...(nextEventName ? { eventName: nextEventName } : {}), ...(nextEventName ? { eventName: nextEventName } : {}),
...(nextTag ? { tag: nextTag } : {}), ...(nextTag ? { tag: nextTag } : {}),
...(nextStatus ? { status: nextStatus } : {}),
...(nextNewlyAdded ? { newlyAdded: "true" } : {}),
...(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 } : {}),
@@ -115,6 +124,17 @@ watch(
<CatalogTextFilter v-model="setName" label="Set" placeholder="SV151" /> <CatalogTextFilter v-model="setName" label="Set" placeholder="SV151" />
<CatalogTextFilter v-model="eventName" label="Event" placeholder="Worlds" /> <CatalogTextFilter v-model="eventName" label="Event" placeholder="Worlds" />
<CatalogTextFilter v-model="tag" label="Tag" placeholder="binder" /> <CatalogTextFilter v-model="tag" label="Tag" placeholder="binder" />
<CatalogToggle v-model="newlyAdded" label="Newly added" />
<label>
Status
<select v-model="status">
<option value="">Available and pending</option>
<option value="AVAILABLE">Available</option>
<option value="PENDING">Pending</option>
<option value="SOLD">Sold only</option>
<option value="ALL">Include sold</option>
</select>
</label>
<label <label
>Category >Category
<select v-model="category"> <select v-model="category">
+4
View File
@@ -7,6 +7,8 @@ export async function searchCatalog({
setName = "", setName = "",
eventName = "", eventName = "",
tag = "", tag = "",
status = "",
newlyAdded = false,
minPriceMinor, minPriceMinor,
maxPriceMinor, maxPriceMinor,
sort = "recent", sort = "recent",
@@ -24,6 +26,8 @@ export async function searchCatalog({
if (setName) params.set("setName", setName); if (setName) params.set("setName", setName);
if (eventName) params.set("eventName", eventName); if (eventName) params.set("eventName", eventName);
if (tag) params.set("tag", tag); if (tag) params.set("tag", tag);
if (status) params.set("status", status);
if (newlyAdded) params.set("newlyAdded", "true");
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
@@ -165,6 +165,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
- 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. - 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.
- Public catalog filtering now supports explicit Available, Pending, Sold, and All status modes plus a 30-day Newly added toggle through the shared search API and atomic controls.
## Local Commands ## Local Commands