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(
+
+