diff --git a/apps/api/src/modules/listings/repositories/listing-repository.js b/apps/api/src/modules/listings/repositories/listing-repository.js index d61aa74..d317348 100644 --- a/apps/api/src/modules/listings/repositories/listing-repository.js +++ b/apps/api/src/modules/listings/repositories/listing-repository.js @@ -78,6 +78,8 @@ export function createListingRepository(db) { setName, eventName, tag, + status, + newlyAdded = false, minPriceMinor, maxPriceMinor, page = 1, @@ -89,16 +91,26 @@ export function createListingRepository(db) { const conditions = [ inArray( listings.status, - includeSold - ? ["AVAILABLE", "PENDING", "CLOSED"] - : ["AVAILABLE", "PENDING"], + status === "SOLD" + ? ["CLOSED"] + : status === "PENDING" + ? ["PENDING"] + : status === "AVAILABLE" + ? ["AVAILABLE"] + : includeSold + ? ["AVAILABLE", "PENDING", "CLOSED"] + : ["AVAILABLE", "PENDING"], ), inArray( 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) { conditions.push(eq(products.categoryId, category)); } diff --git a/apps/api/src/modules/search/domain/search-query.js b/apps/api/src/modules/search/domain/search-query.js index f9a7dec..4c679e4 100644 --- a/apps/api/src/modules/search/domain/search-query.js +++ b/apps/api/src/modules/search/domain/search-query.js @@ -22,6 +22,8 @@ export function createSearchQuery({ setName, eventName, tag, + status, + newlyAdded = false, minPriceMinor, maxPriceMinor, page = 1, @@ -39,6 +41,10 @@ export function createSearchQuery({ 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 ( typeof minPriceMinor !== "undefined" && (!Number.isInteger(minPriceMinor) || minPriceMinor < 0) @@ -87,6 +93,8 @@ export function createSearchQuery({ page, limit, includeSold, + ...(status ? { status } : {}), + ...(newlyAdded ? { newlyAdded: true } : {}), ...(sort !== "recent" ? { sort } : {}), }; } 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 d69b3f6..55c41bc 100644 --- a/apps/api/src/modules/search/routes/public-search-routes.js +++ b/apps/api/src/modules/search/routes/public-search-routes.js @@ -35,6 +35,8 @@ export async function registerPublicSearchRoutes(app, options) { setName: request.query.setName, eventName: request.query.eventName, tag: request.query.tag, + status: request.query.status, + newlyAdded: request.query.newlyAdded === "true" || request.query.newlyAdded === true, 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 d132f14..5e1afc2 100644 --- a/apps/api/tests/domain/search-query.test.js +++ b/apps/api/tests/domain/search-query.test.js @@ -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", () => { assert.equal( normalizeSearchQuery(" Pikachu #199 / holo "), diff --git a/apps/api/tests/http/public-search-routes.test.js b/apps/api/tests/http/public-search-routes.test.js index f12675c..399baec 100644 --- a/apps/api/tests/http/public-search-routes.test.js +++ b/apps/api/tests/http/public-search-routes.test.js @@ -15,6 +15,8 @@ test("returns public search results with pagination metadata", async (testContex assert.equal(filters.setName, "SV151"); assert.equal(filters.eventName, "Worlds"); assert.equal(filters.tag, "binder"); + assert.equal(filters.status, "SOLD"); + assert.equal(filters.newlyAdded, true); return { items: [{ id: "listing-1", status: "CLOSED" }], 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({ 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); diff --git a/apps/web/src/components/atoms/CatalogToggle.scss b/apps/web/src/components/atoms/CatalogToggle.scss new file mode 100644 index 0000000..0896be6 --- /dev/null +++ b/apps/web/src/components/atoms/CatalogToggle.scss @@ -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); +} diff --git a/apps/web/src/components/atoms/CatalogToggle.stories.js b/apps/web/src/components/atoms/CatalogToggle.stories.js new file mode 100644 index 0000000..4d39694 --- /dev/null +++ b/apps/web/src/components/atoms/CatalogToggle.stories.js @@ -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 } }; diff --git a/apps/web/src/components/atoms/CatalogToggle.vue b/apps/web/src/components/atoms/CatalogToggle.vue new file mode 100644 index 0000000..76334c5 --- /dev/null +++ b/apps/web/src/components/atoms/CatalogToggle.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/apps/web/src/composables/useCatalogSearch.js b/apps/web/src/composables/useCatalogSearch.js index 2b41794..977f5e6 100644 --- a/apps/web/src/composables/useCatalogSearch.js +++ b/apps/web/src/composables/useCatalogSearch.js @@ -9,6 +9,8 @@ export function useCatalogSearch({ initialSetName = "", initialEventName = "", initialTag = "", + initialStatus = "", + initialNewlyAdded = false, initialMinPriceMinor, initialMaxPriceMinor, initialSort = "recent", @@ -20,6 +22,8 @@ export function useCatalogSearch({ const setName = ref(initialSetName); const eventName = ref(initialEventName); const tag = ref(initialTag); + const status = ref(initialStatus); + const newlyAdded = ref(initialNewlyAdded); const minPriceMinor = ref(initialMinPriceMinor); const maxPriceMinor = ref(initialMaxPriceMinor); const sort = ref(initialSort); @@ -45,6 +49,8 @@ export function useCatalogSearch({ setName: setName.value, eventName: eventName.value, tag: tag.value, + status: status.value, + newlyAdded: newlyAdded.value, minPriceMinor: minPriceMinor.value, maxPriceMinor: maxPriceMinor.value, sort: sort.value, @@ -59,7 +65,7 @@ export function useCatalogSearch({ } 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; else load(); @@ -80,6 +86,8 @@ export function useCatalogSearch({ setName, eventName, tag, + status, + newlyAdded, minPriceMinor, maxPriceMinor, sort, diff --git a/apps/web/src/pages/CatalogPage.vue b/apps/web/src/pages/CatalogPage.vue index cf64865..2b4c133 100644 --- a/apps/web/src/pages/CatalogPage.vue +++ b/apps/web/src/pages/CatalogPage.vue @@ -10,6 +10,7 @@ import { import ListingCard from "@/components/molecules/ListingCard.vue"; import CatalogSortSelect from "@/components/atoms/CatalogSortSelect.vue"; import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue"; +import CatalogToggle from "@/components/atoms/CatalogToggle.vue"; const route = useRoute(); const router = useRouter(); @@ -22,6 +23,8 @@ const { setName, eventName, tag, + status, + newlyAdded, minPriceMinor, maxPriceMinor, sort, @@ -40,6 +43,8 @@ const { 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 : "", + initialStatus: typeof route.query.status === "string" ? route.query.status : "", + initialNewlyAdded: route.query.newlyAdded === "true", initialMinPriceMinor: Number.parseInt(route.query.min, 10) || undefined, initialMaxPriceMinor: Number.parseInt(route.query.max, 10) || undefined, initialSort: @@ -64,7 +69,7 @@ onMounted(async () => { }); watch( - [query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort, page], + [query, category, conditionId, setName, eventName, tag, status, newlyAdded, minPriceMinor, maxPriceMinor, sort, page], ([ nextQuery, nextCategory, @@ -72,6 +77,8 @@ watch( nextSetName, nextEventName, nextTag, + nextStatus, + nextNewlyAdded, nextMin, nextMax, nextSort, @@ -85,6 +92,8 @@ watch( ...(nextSetName ? { setName: nextSetName } : {}), ...(nextEventName ? { eventName: nextEventName } : {}), ...(nextTag ? { tag: nextTag } : {}), + ...(nextStatus ? { status: nextStatus } : {}), + ...(nextNewlyAdded ? { newlyAdded: "true" } : {}), ...(typeof nextMin === "number" ? { min: String(nextMin) } : {}), ...(typeof nextMax === "number" ? { max: String(nextMax) } : {}), ...(nextSort !== "recent" ? { sort: nextSort } : {}), @@ -115,6 +124,17 @@ watch( + +