diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index c0c3364..d61aa74 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -75,6 +75,9 @@ export function createListingRepository(db) { text = "", category, conditionId, + setName, + eventName, + tag, minPriceMinor, maxPriceMinor, page = 1, @@ -104,6 +107,18 @@ export function createListingRepository(db) { 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") { conditions.push(sql`listings.asking_price_minor >= ${minPriceMinor}`); } diff --git a/apps/api/src/modules/search/domain/search-query.js b/apps/api/src/modules/search/domain/search-query.js index b37e7e5..f9a7dec 100644 --- a/apps/api/src/modules/search/domain/search-query.js +++ b/apps/api/src/modules/search/domain/search-query.js @@ -19,6 +19,9 @@ export function createSearchQuery({ query = "", category, conditionId, + setName, + eventName, + tag, minPriceMinor, maxPriceMinor, page = 1, @@ -70,6 +73,15 @@ export function createSearchQuery({ ...(typeof conditionId === "string" && conditionId.trim().length > 0 ? { 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 maxPriceMinor === "number" ? { maxPriceMinor } : {}), page, diff --git a/apps/api/src/modules/search/routes/public-search-routes.js b/apps/api/src/modules/search/routes/public-search-routes.js index 60b977d..d69b3f6 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -32,6 +32,9 @@ export async function registerPublicSearchRoutes(app, options) { query: request.query.query, category: request.query.category, conditionId: request.query.conditionId, + setName: request.query.setName, + eventName: request.query.eventName, + tag: request.query.tag, minPriceMinor: coerceNumber( request.query.minPriceMinor, "minPriceMinor", diff --git a/apps/api/tests/domain/search-query.test.js b/apps/api/tests/domain/search-query.test.js index 6c57159..d132f14 100644 --- a/apps/api/tests/domain/search-query.test.js +++ b/apps/api/tests/domain/search-query.test.js @@ -11,6 +11,9 @@ test("normalizes a public search query and keeps filter values within bounds", ( const query = createSearchQuery({ query: " Charizard ex ", category: "Cards", + setName: "SV151", + eventName: "Worlds", + tag: "Binder", minPriceMinor: 1000, maxPriceMinor: 5000, 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.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.maxPriceMinor, 5000); assert.equal(query.page, 1); diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js index cdc7505..f12675c 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -12,6 +12,9 @@ test("returns public search results with pagination metadata", async (testContex assert.equal(filters.includeSold, true); assert.equal(filters.sort, "price_low"); assert.equal(filters.conditionId, "condition-1"); + assert.equal(filters.setName, "SV151"); + assert.equal(filters.eventName, "Worlds"); + assert.equal(filters.tag, "binder"); return { items: [{ id: "listing-1", status: "CLOSED" }], 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({ 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); diff --git a/apps/web/src/components/atoms/CatalogTextFilter.scss b/apps/web/src/components/atoms/CatalogTextFilter.scss new file mode 100644 index 0000000..19e4b85 --- /dev/null +++ b/apps/web/src/components/atoms/CatalogTextFilter.scss @@ -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); +} diff --git a/apps/web/src/components/atoms/CatalogTextFilter.stories.js b/apps/web/src/components/atoms/CatalogTextFilter.stories.js new file mode 100644 index 0000000..c1098b3 --- /dev/null +++ b/apps/web/src/components/atoms/CatalogTextFilter.stories.js @@ -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' } }; diff --git a/apps/web/src/components/atoms/CatalogTextFilter.vue b/apps/web/src/components/atoms/CatalogTextFilter.vue new file mode 100644 index 0000000..e8f122c --- /dev/null +++ b/apps/web/src/components/atoms/CatalogTextFilter.vue @@ -0,0 +1,25 @@ + + + + + diff --git a/apps/web/src/composables/useCatalogSearch.js b/apps/web/src/composables/useCatalogSearch.js index c1d3cca..2b41794 100644 --- a/apps/web/src/composables/useCatalogSearch.js +++ b/apps/web/src/composables/useCatalogSearch.js @@ -6,6 +6,9 @@ export function useCatalogSearch({ initialQuery = "", initialCategory = "", initialConditionId = "", + initialSetName = "", + initialEventName = "", + initialTag = "", initialMinPriceMinor, initialMaxPriceMinor, initialSort = "recent", @@ -14,6 +17,9 @@ export function useCatalogSearch({ const query = ref(initialQuery); const category = ref(initialCategory); const conditionId = ref(initialConditionId); + const setName = ref(initialSetName); + const eventName = ref(initialEventName); + const tag = ref(initialTag); const minPriceMinor = ref(initialMinPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor); const sort = ref(initialSort); @@ -36,6 +42,9 @@ export function useCatalogSearch({ query: query.value, category: category.value, conditionId: conditionId.value, + setName: setName.value, + eventName: eventName.value, + tag: tag.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, @@ -50,7 +59,7 @@ export function useCatalogSearch({ } watch( - [query, category, conditionId, minPriceMinor, maxPriceMinor, sort], + [query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort], () => { if (page.value !== 1) page.value = 1; else load(); @@ -68,6 +77,9 @@ export function useCatalogSearch({ query, category, conditionId, + setName, + eventName, + tag, minPriceMinor, maxPriceMinor, sort, diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue index dc309e4..cf64865 100644 --- a/apps/web/src/pages/CatalogPage.vue +++ b/apps/web/src/pages/CatalogPage.vue @@ -9,6 +9,7 @@ import { } from "@/services/catalog-service.js"; import ListingCard from "@/components/molecules/ListingCard.vue"; import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue"; +import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue"; const route = useRoute(); const router = useRouter(); @@ -18,6 +19,9 @@ const { query, category, conditionId, + setName, + eventName, + tag, minPriceMinor, maxPriceMinor, sort, @@ -33,6 +37,9 @@ const { typeof route.query.category === "string" ? route.query.category : "", initialConditionId: 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, initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialSort: @@ -57,11 +64,14 @@ onMounted(async () => { }); watch( - [query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], + [query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort, page], ([ nextQuery, nextCategory, nextCondition, + nextSetName, + nextEventName, + nextTag, nextMin, nextMax, nextSort, @@ -72,6 +82,9 @@ watch( ...(nextQuery ? { q: nextQuery } : {}), ...(nextCategory ? { category: nextCategory } : {}), ...(nextCondition ? { conditionId: nextCondition } : {}), + ...(nextSetName ? { setName: nextSetName } : {}), + ...(nextEventName ? { eventName: nextEventName } : {}), + ...(nextTag ? { tag: nextTag } : {}), ...(typeof nextMin === "number" ? { min: String(nextMin) } : {}), ...(typeof nextMax === "number" ? { max: String(nextMax) } : {}), ...(nextSort !== "recent" ? { sort: nextSort } : {}), @@ -99,6 +112,9 @@ watch( />
+ + +