@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user