This commit is contained in:
@@ -78,6 +78,8 @@ export function createListingRepository(db) {
|
|||||||
setName,
|
setName,
|
||||||
eventName,
|
eventName,
|
||||||
tag,
|
tag,
|
||||||
|
status,
|
||||||
|
newlyAdded = false,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
maxPriceMinor,
|
maxPriceMinor,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -89,16 +91,26 @@ export function createListingRepository(db) {
|
|||||||
const conditions = [
|
const conditions = [
|
||||||
inArray(
|
inArray(
|
||||||
listings.status,
|
listings.status,
|
||||||
includeSold
|
status === "SOLD"
|
||||||
? ["AVAILABLE", "PENDING", "CLOSED"]
|
? ["CLOSED"]
|
||||||
: ["AVAILABLE", "PENDING"],
|
: status === "PENDING"
|
||||||
|
? ["PENDING"]
|
||||||
|
: status === "AVAILABLE"
|
||||||
|
? ["AVAILABLE"]
|
||||||
|
: includeSold
|
||||||
|
? ["AVAILABLE", "PENDING", "CLOSED"]
|
||||||
|
: ["AVAILABLE", "PENDING"],
|
||||||
),
|
),
|
||||||
inArray(
|
inArray(
|
||||||
inventoryItems.status,
|
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) {
|
if (typeof category === "string" && category.trim().length > 0) {
|
||||||
conditions.push(eq(products.categoryId, category));
|
conditions.push(eq(products.categoryId, category));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ export function createSearchQuery({
|
|||||||
setName,
|
setName,
|
||||||
eventName,
|
eventName,
|
||||||
tag,
|
tag,
|
||||||
|
status,
|
||||||
|
newlyAdded = false,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
maxPriceMinor,
|
maxPriceMinor,
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -39,6 +41,10 @@ export function createSearchQuery({
|
|||||||
throw new Error("limit must be between 1 and 100");
|
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 (
|
if (
|
||||||
typeof minPriceMinor !== "undefined" &&
|
typeof minPriceMinor !== "undefined" &&
|
||||||
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
(!Number.isInteger(minPriceMinor) || minPriceMinor < 0)
|
||||||
@@ -87,6 +93,8 @@ export function createSearchQuery({
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
includeSold,
|
includeSold,
|
||||||
|
...(status ? { status } : {}),
|
||||||
|
...(newlyAdded ? { newlyAdded: true } : {}),
|
||||||
...(sort !== "recent" ? { sort } : {}),
|
...(sort !== "recent" ? { sort } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export async function registerPublicSearchRoutes(app, options) {
|
|||||||
setName: request.query.setName,
|
setName: request.query.setName,
|
||||||
eventName: request.query.eventName,
|
eventName: request.query.eventName,
|
||||||
tag: request.query.tag,
|
tag: request.query.tag,
|
||||||
|
status: request.query.status,
|
||||||
|
newlyAdded: request.query.newlyAdded === "true" || request.query.newlyAdded === true,
|
||||||
minPriceMinor: coerceNumber(
|
minPriceMinor: coerceNumber(
|
||||||
request.query.minPriceMinor,
|
request.query.minPriceMinor,
|
||||||
"minPriceMinor",
|
"minPriceMinor",
|
||||||
|
|||||||
@@ -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", () => {
|
test("normalizes whitespace and punctuation in free-text search terms", () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeSearchQuery(" Pikachu #199 / holo "),
|
normalizeSearchQuery(" Pikachu #199 / holo "),
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ test("returns public search results with pagination metadata", async (testContex
|
|||||||
assert.equal(filters.setName, "SV151");
|
assert.equal(filters.setName, "SV151");
|
||||||
assert.equal(filters.eventName, "Worlds");
|
assert.equal(filters.eventName, "Worlds");
|
||||||
assert.equal(filters.tag, "binder");
|
assert.equal(filters.tag, "binder");
|
||||||
|
assert.equal(filters.status, "SOLD");
|
||||||
|
assert.equal(filters.newlyAdded, true);
|
||||||
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 },
|
||||||
@@ -26,7 +28,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&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);
|
assert.equal(response.statusCode, 200);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 } };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
modelValue: { type: Boolean, required: true },
|
||||||
|
label: { type: String, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
defineEmits(['update:modelValue']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<label class="catalog-toggle">
|
||||||
|
<input
|
||||||
|
:checked="modelValue"
|
||||||
|
type="checkbox"
|
||||||
|
@change="$emit('update:modelValue', $event.target.checked)"
|
||||||
|
/>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use './CatalogToggle.scss';
|
||||||
|
</style>
|
||||||
@@ -9,6 +9,8 @@ export function useCatalogSearch({
|
|||||||
initialSetName = "",
|
initialSetName = "",
|
||||||
initialEventName = "",
|
initialEventName = "",
|
||||||
initialTag = "",
|
initialTag = "",
|
||||||
|
initialStatus = "",
|
||||||
|
initialNewlyAdded = false,
|
||||||
initialMinPriceMinor,
|
initialMinPriceMinor,
|
||||||
initialMaxPriceMinor,
|
initialMaxPriceMinor,
|
||||||
initialSort = "recent",
|
initialSort = "recent",
|
||||||
@@ -20,6 +22,8 @@ export function useCatalogSearch({
|
|||||||
const setName = ref(initialSetName);
|
const setName = ref(initialSetName);
|
||||||
const eventName = ref(initialEventName);
|
const eventName = ref(initialEventName);
|
||||||
const tag = ref(initialTag);
|
const tag = ref(initialTag);
|
||||||
|
const status = ref(initialStatus);
|
||||||
|
const newlyAdded = ref(initialNewlyAdded);
|
||||||
const minPriceMinor = ref(initialMinPriceMinor);
|
const minPriceMinor = ref(initialMinPriceMinor);
|
||||||
const maxPriceMinor = ref(initialMaxPriceMinor);
|
const maxPriceMinor = ref(initialMaxPriceMinor);
|
||||||
const sort = ref(initialSort);
|
const sort = ref(initialSort);
|
||||||
@@ -45,6 +49,8 @@ export function useCatalogSearch({
|
|||||||
setName: setName.value,
|
setName: setName.value,
|
||||||
eventName: eventName.value,
|
eventName: eventName.value,
|
||||||
tag: tag.value,
|
tag: tag.value,
|
||||||
|
status: status.value,
|
||||||
|
newlyAdded: newlyAdded.value,
|
||||||
minPriceMinor: minPriceMinor.value,
|
minPriceMinor: minPriceMinor.value,
|
||||||
maxPriceMinor: maxPriceMinor.value,
|
maxPriceMinor: maxPriceMinor.value,
|
||||||
sort: sort.value,
|
sort: sort.value,
|
||||||
@@ -59,7 +65,7 @@ export function useCatalogSearch({
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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;
|
if (page.value !== 1) page.value = 1;
|
||||||
else load();
|
else load();
|
||||||
@@ -80,6 +86,8 @@ export function useCatalogSearch({
|
|||||||
setName,
|
setName,
|
||||||
eventName,
|
eventName,
|
||||||
tag,
|
tag,
|
||||||
|
status,
|
||||||
|
newlyAdded,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
maxPriceMinor,
|
maxPriceMinor,
|
||||||
sort,
|
sort,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
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";
|
import CatalogTextFilter from "@/components/atoms/CatalogTextFilter.vue";
|
||||||
|
import CatalogToggle from "@/components/atoms/CatalogToggle.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -22,6 +23,8 @@ const {
|
|||||||
setName,
|
setName,
|
||||||
eventName,
|
eventName,
|
||||||
tag,
|
tag,
|
||||||
|
status,
|
||||||
|
newlyAdded,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
maxPriceMinor,
|
maxPriceMinor,
|
||||||
sort,
|
sort,
|
||||||
@@ -40,6 +43,8 @@ const {
|
|||||||
initialSetName: typeof route.query.setName === "string" ? route.query.setName : "",
|
initialSetName: typeof route.query.setName === "string" ? route.query.setName : "",
|
||||||
initialEventName: typeof route.query.eventName === "string" ? route.query.eventName : "",
|
initialEventName: typeof route.query.eventName === "string" ? route.query.eventName : "",
|
||||||
initialTag: typeof route.query.tag === "string" ? route.query.tag : "",
|
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,
|
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:
|
||||||
@@ -64,7 +69,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[query, category, conditionId, setName, eventName, tag, minPriceMinor, maxPriceMinor, sort, page],
|
[query, category, conditionId, setName, eventName, tag, status, newlyAdded, minPriceMinor, maxPriceMinor, sort, page],
|
||||||
([
|
([
|
||||||
nextQuery,
|
nextQuery,
|
||||||
nextCategory,
|
nextCategory,
|
||||||
@@ -72,6 +77,8 @@ watch(
|
|||||||
nextSetName,
|
nextSetName,
|
||||||
nextEventName,
|
nextEventName,
|
||||||
nextTag,
|
nextTag,
|
||||||
|
nextStatus,
|
||||||
|
nextNewlyAdded,
|
||||||
nextMin,
|
nextMin,
|
||||||
nextMax,
|
nextMax,
|
||||||
nextSort,
|
nextSort,
|
||||||
@@ -85,6 +92,8 @@ watch(
|
|||||||
...(nextSetName ? { setName: nextSetName } : {}),
|
...(nextSetName ? { setName: nextSetName } : {}),
|
||||||
...(nextEventName ? { eventName: nextEventName } : {}),
|
...(nextEventName ? { eventName: nextEventName } : {}),
|
||||||
...(nextTag ? { tag: nextTag } : {}),
|
...(nextTag ? { tag: nextTag } : {}),
|
||||||
|
...(nextStatus ? { status: nextStatus } : {}),
|
||||||
|
...(nextNewlyAdded ? { newlyAdded: "true" } : {}),
|
||||||
...(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 } : {}),
|
||||||
@@ -115,6 +124,17 @@ watch(
|
|||||||
<CatalogTextFilter v-model="setName" label="Set" placeholder="SV151" />
|
<CatalogTextFilter v-model="setName" label="Set" placeholder="SV151" />
|
||||||
<CatalogTextFilter v-model="eventName" label="Event" placeholder="Worlds" />
|
<CatalogTextFilter v-model="eventName" label="Event" placeholder="Worlds" />
|
||||||
<CatalogTextFilter v-model="tag" label="Tag" placeholder="binder" />
|
<CatalogTextFilter v-model="tag" label="Tag" placeholder="binder" />
|
||||||
|
<CatalogToggle v-model="newlyAdded" label="Newly added" />
|
||||||
|
<label>
|
||||||
|
Status
|
||||||
|
<select v-model="status">
|
||||||
|
<option value="">Available and pending</option>
|
||||||
|
<option value="AVAILABLE">Available</option>
|
||||||
|
<option value="PENDING">Pending</option>
|
||||||
|
<option value="SOLD">Sold only</option>
|
||||||
|
<option value="ALL">Include sold</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label
|
<label
|
||||||
>Category
|
>Category
|
||||||
<select v-model="category">
|
<select v-model="category">
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export async function searchCatalog({
|
|||||||
setName = "",
|
setName = "",
|
||||||
eventName = "",
|
eventName = "",
|
||||||
tag = "",
|
tag = "",
|
||||||
|
status = "",
|
||||||
|
newlyAdded = false,
|
||||||
minPriceMinor,
|
minPriceMinor,
|
||||||
maxPriceMinor,
|
maxPriceMinor,
|
||||||
sort = "recent",
|
sort = "recent",
|
||||||
@@ -24,6 +26,8 @@ export async function searchCatalog({
|
|||||||
if (setName) params.set("setName", setName);
|
if (setName) params.set("setName", setName);
|
||||||
if (eventName) params.set("eventName", eventName);
|
if (eventName) params.set("eventName", eventName);
|
||||||
if (tag) params.set("tag", tag);
|
if (tag) params.set("tag", tag);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (newlyAdded) params.set("newlyAdded", "true");
|
||||||
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")
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
|
|||||||
- 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.
|
- 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.
|
||||||
|
- Public catalog filtering now supports explicit Available, Pending, Sold, and All status modes plus a 30-day Newly added toggle through the shared search API and atomic controls.
|
||||||
|
|
||||||
## Local Commands
|
## Local Commands
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user