diff --git a/apps/api/src/modules/inventory/routes/public-lookup-routes.js b/apps/api/src/modules/inventory/routes/public-lookup-routes.js index d7b798b..da09995 100644 --- a/apps/api/src/modules/inventory/routes/public-lookup-routes.js +++ b/apps/api/src/modules/inventory/routes/public-lookup-routes.js @@ -4,4 +4,8 @@ export async function registerPublicLookupRoutes(app, options) { app.get('/public/lookups/categories', async () => ({ data: await catalogLookupRepository.listCategories(), })); + + app.get('/public/lookups/conditions', async () => ({ + data: (await catalogLookupRepository.listConditions()).map(({ id, name, code }) => ({ id, name, code })), + })); } \ No newline at end of file diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index 4f87fb6..b7cb17a 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -48,6 +48,7 @@ export function createListingRepository(db) { const { text = '', category, + conditionId, minPriceMinor, maxPriceMinor, page = 1, @@ -68,6 +69,10 @@ export function createListingRepository(db) { conditions.push(eq(products.categoryId, category)); } + if (typeof conditionId === 'string' && conditionId.trim().length > 0) { + conditions.push(eq(inventoryItems.conditionId, conditionId)); + } + 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 d6e8a1d..f6cd043 100644 --- a/apps/api/src/modules/search/domain/search-query.js +++ b/apps/api/src/modules/search/domain/search-query.js @@ -18,6 +18,7 @@ export function normalizeSearchQuery(value) { export function createSearchQuery({ query = '', category, + conditionId, minPriceMinor, maxPriceMinor, page = 1, @@ -58,6 +59,7 @@ export function createSearchQuery({ return { text, ...(typeof category === 'string' && category.trim().length > 0 ? { category: category.trim() } : {}), + ...(typeof conditionId === 'string' && conditionId.trim().length > 0 ? { conditionId: conditionId.trim() } : {}), ...(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 4dca836..7155aba 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -27,6 +27,7 @@ export async function registerPublicSearchRoutes(app, options) { const filters = { query: request.query.query, category: request.query.category, + conditionId: request.query.conditionId, minPriceMinor: coerceNumber(request.query.minPriceMinor, 'minPriceMinor', undefined), maxPriceMinor: coerceNumber(request.query.maxPriceMinor, 'maxPriceMinor', undefined), page: coerceNumber(request.query.page, '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 6551d79..1d997d0 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -11,6 +11,7 @@ test('returns public search results with pagination metadata', async (testContex assert.equal(filters.limit, 5); assert.equal(filters.includeSold, true); assert.equal(filters.sort, 'price_low'); + assert.equal(filters.conditionId, 'condition-1'); return { items: [{ id: 'listing-1', status: 'CLOSED' }], pagination: { page: 2, limit: 5, total: 6, totalPages: 2 }, @@ -22,7 +23,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', + url: '/api/v1/public/search?query=charizard&page=2&limit=5&includeSold=true&sort=price_low&conditionId=condition-1', }); assert.equal(response.statusCode, 200); @@ -59,4 +60,17 @@ test('returns active public category options', async (testContext) => { const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/categories' }); assert.equal(response.statusCode, 200); assert.deepEqual(response.json().data, [{ id: 'category-1', name: 'Trading Cards' }]); +}); + +test('returns public condition options without category internals', async (testContext) => { + const app = buildApp({ + catalogLookupRepository: { + async listConditions() { return [{ id: 'condition-1', name: 'Near Mint', code: 'NM', categoryId: 'private-category' }]; }, + }, + }); + testContext.after(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/public/lookups/conditions' }); + assert.equal(response.statusCode, 200); + assert.deepEqual(response.json().data, [{ id: 'condition-1', name: 'Near Mint', code: 'NM' }]); }); \ No newline at end of file diff --git a/apps/web/src/composables/useCatalogSearch.js b/apps/web/src/composables/useCatalogSearch.js index 23aa999..d14230d 100644 --- a/apps/web/src/composables/useCatalogSearch.js +++ b/apps/web/src/composables/useCatalogSearch.js @@ -2,9 +2,10 @@ import { ref, watch } from 'vue'; import { searchCatalog } from '@/services/catalog-service.js'; -export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) { +export function useCatalogSearch({ initialQuery = '', initialCategory = '', initialConditionId = '', initialMinPriceMinor, initialMaxPriceMinor, initialSort = 'recent', initialPage = 1 } = {}) { const query = ref(initialQuery); const category = ref(initialCategory); + const conditionId = ref(initialConditionId); const minPriceMinor = ref(initialMinPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor); const sort = ref(initialSort); @@ -20,7 +21,7 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init error.value = null; try { - const nextResult = await searchCatalog({ query: query.value, category: category.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value }); + const nextResult = await searchCatalog({ query: query.value, category: category.value, conditionId: conditionId.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, page: page.value }); if (currentRequestId === requestId) result.value = nextResult; } catch (requestError) { if (currentRequestId === requestId) error.value = requestError; @@ -29,7 +30,7 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init } } - watch([query, category, minPriceMinor, maxPriceMinor, sort], () => { + watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort], () => { if (page.value !== 1) page.value = 1; else load(); }); @@ -41,5 +42,5 @@ export function useCatalogSearch({ initialQuery = '', initialCategory = '', init } } - return { query, category, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage }; + return { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage }; } \ No newline at end of file diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue index a682953..dca5719 100644 --- a/apps/web/src/pages/CatalogPage.vue +++ b/apps/web/src/pages/CatalogPage.vue @@ -3,15 +3,17 @@ import { onMounted, ref, watch } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import { useCatalogSearch } from '@/composables/useCatalogSearch.js'; -import { listPublicCategories } from '@/services/catalog-service.js'; +import { listPublicCategories, listPublicConditions } from '@/services/catalog-service.js'; import ListingCard from '@/components/molecules/ListingCard.vue'; const route = useRoute(); const router = useRouter(); const categories = ref([]); -const { query, category, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage } = useCatalogSearch({ +const conditions = ref([]); +const { query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page, result, isLoading, error, load, goToPage } = useCatalogSearch({ initialQuery: typeof route.query.q === 'string' ? route.query.q : '', initialCategory: typeof route.query.category === 'string' ? route.query.category : '', + initialConditionId: typeof route.query.conditionId === 'string' ? route.query.conditionId : '', initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined, initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialSort: typeof route.query.sort === 'string' ? route.query.sort : 'recent', @@ -19,14 +21,19 @@ const { query, category, minPriceMinor, maxPriceMinor, sort, page, result, isLoa }); onMounted(async () => { - await Promise.all([load(), listPublicCategories().then((items) => { categories.value = items; }).catch(() => {})]); + await Promise.all([ + load(), + listPublicCategories().then((items) => { categories.value = items; }).catch(() => {}), + listPublicConditions().then((items) => { conditions.value = items; }).catch(() => {}), + ]); }); -watch([query, category, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery, nextCategory, nextMin, nextMax, nextSort, nextPage]) => { +watch([query, category, conditionId, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery, nextCategory, nextCondition, nextMin, nextMax, nextSort, nextPage]) => { router.replace({ query: { ...(nextQuery ? { q: nextQuery } : {}), ...(nextCategory ? { category: nextCategory } : {}), + ...(nextCondition ? { conditionId: nextCondition } : {}), ...(typeof nextMin === 'number' ? { min: String(nextMin) } : {}), ...(typeof nextMax === 'number' ? { max: String(nextMax) } : {}), ...(nextSort !== 'recent' ? { sort: nextSort } : {}), @@ -48,6 +55,7 @@ watch([query, category, minPriceMinor, maxPriceMinor, sort, page], ([nextQuery,
+ diff --git a/apps/web/src/services/catalog-service.js b/apps/web/src/services/catalog-service.js index 6d2a77c..667de91 100644 --- a/apps/web/src/services/catalog-service.js +++ b/apps/web/src/services/catalog-service.js @@ -1,9 +1,10 @@ const apiBaseUrl = import.meta.env?.VITE_API_BASE_URL ?? '/api/v1'; -export async function searchCatalog({ query = '', category = '', minPriceMinor, maxPriceMinor, sort = 'recent', page = 1, limit = 20 } = {}) { +export async function searchCatalog({ query = '', category = '', conditionId = '', minPriceMinor, maxPriceMinor, sort = 'recent', page = 1, limit = 20 } = {}) { const params = new URLSearchParams({ query, page: String(page), limit: String(limit) }); if (sort !== 'recent') params.set('sort', sort); if (category) params.set('category', category); + if (conditionId) params.set('conditionId', conditionId); if (typeof minPriceMinor === 'number') params.set('minPriceMinor', String(minPriceMinor)); if (typeof maxPriceMinor === 'number') params.set('maxPriceMinor', String(maxPriceMinor)); const response = await fetch(`${apiBaseUrl}/public/search?${params}`); @@ -22,6 +23,12 @@ export async function listPublicCategories() { return (await response.json()).data; } +export async function listPublicConditions() { + const response = await fetch(`${apiBaseUrl}/public/lookups/conditions`); + if (!response.ok) throw new Error('Conditions are unavailable.'); + return (await response.json()).data; +} + export async function getPublicListing(id) { const response = await fetch(`${apiBaseUrl}/public/listings/${encodeURIComponent(id)}`);