Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 152 additions & 0 deletions __tests__/pages/api/db/fetch-products.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
jest.mock("@/utils/db/db-service", () => ({
fetchAllProductsFromDb: jest.fn(),
getEventCount: jest.fn(),
}));

jest.mock("@/utils/rate-limit", () => ({
applyRateLimit: jest.fn(() => true),
}));

import type { NextApiRequest, NextApiResponse } from "next";
import handler from "@/pages/api/db/fetch-products";
import { fetchAllProductsFromDb, getEventCount } from "@/utils/db/db-service";
import { applyRateLimit } from "@/utils/rate-limit";

const mockedFetchAllProductsFromDb =
fetchAllProductsFromDb as jest.MockedFunction<typeof fetchAllProductsFromDb>;
const mockedGetEventCount = getEventCount as jest.MockedFunction<
typeof getEventCount
>;
const mockedApplyRateLimit = applyRateLimit as jest.MockedFunction<
typeof applyRateLimit
>;

type MockApiResponse = NextApiResponse & {
body: unknown;
headers: Record<string, string>;
statusCode: number;
};

const createResponse = () => {
const response = {
headers: {} as Record<string, string>,
statusCode: 200,
body: undefined as unknown,
setHeader(name: string, value: string) {
this.headers[name] = value;
return this;
},
status(code: number) {
this.statusCode = code;
return this;
},
json(payload: unknown) {
this.body = payload;
return this;
},
};

return response as unknown as MockApiResponse;
};

const makeRequest = (
query: NextApiRequest["query"],
method = "GET"
): NextApiRequest =>
({
method,
query,
}) as Partial<NextApiRequest> as NextApiRequest;

describe("/api/db/fetch-products", () => {
beforeEach(() => {
mockedFetchAllProductsFromDb.mockReset();
mockedGetEventCount.mockReset();
mockedApplyRateLimit.mockReset();
mockedApplyRateLimit.mockReturnValue(true);
mockedFetchAllProductsFromDb.mockResolvedValue([]);
mockedGetEventCount.mockResolvedValue(0);
});

it("returns 405 for non-GET requests", async () => {
const res = createResponse();

await handler(makeRequest({}, "POST"), res);

expect(res.statusCode).toBe(405);
expect(res.body).toEqual({ error: "Method not allowed" });
expect(mockedFetchAllProductsFromDb).not.toHaveBeenCalled();
expect(mockedGetEventCount).not.toHaveBeenCalled();
});

it("passes parsed array filters through to the db helpers", async () => {
const res = createResponse();

await handler(
makeRequest({
limit: "60",
offset: "120",
since: "100",
until: "200",
search: "hat",
categories: "zapsnag,art",
pubkey: ["pubkey-1", "pubkey-2"],
location: "Delhi",
excludePubkeys: ["blocked-1", "blocked-2"],
}),
res
);

expect(mockedFetchAllProductsFromDb).toHaveBeenCalledWith({
limit: 60,
offset: 120,
since: 100,
until: 200,
search: "hat",
categories: ["zapsnag", "art"],
pubkey: ["pubkey-1", "pubkey-2"],
location: "Delhi",
excludePubkeys: ["blocked-1", "blocked-2"],
});
expect(mockedGetEventCount).toHaveBeenCalledWith({
search: "hat",
categories: ["zapsnag", "art"],
pubkey: ["pubkey-1", "pubkey-2"],
location: "Delhi",
excludePubkeys: ["blocked-1", "blocked-2"],
});
expect(res.statusCode).toBe(200);
expect(res.body).toEqual({ events: [], total: 0 });
});

it("normalizes single-value arrays down to strings", async () => {
const res = createResponse();

await handler(
makeRequest({
pubkey: ["solo-pubkey"],
categories: ["books"],
}),
res
);

expect(mockedFetchAllProductsFromDb).toHaveBeenCalledWith({
limit: 500,
offset: 0,
since: undefined,
until: undefined,
pubkey: "solo-pubkey",
search: undefined,
categories: ["books"],
location: undefined,
excludePubkeys: undefined,
});
expect(mockedGetEventCount).toHaveBeenCalledWith({
pubkey: "solo-pubkey",
search: undefined,
categories: ["books"],
location: undefined,
excludePubkeys: undefined,
});
});
});
4 changes: 4 additions & 0 deletions components/__tests__/display-products.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ describe("DisplayProducts search filtering", () => {
sig: "sig",
},
],
totalEvents: 0,
isLoading: false,
setProductEvents: jest.fn(),
loadMoreProducts: jest.fn(),
refreshProducts: jest.fn(),
addNewlyCreatedProductEvent: jest.fn(),
removeDeletedProductEvent: jest.fn(),
}}
Expand Down
134 changes: 118 additions & 16 deletions components/display-products.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useContext } from "react";
import { FilterParams } from "../utils/types/types";
import { deleteEvent } from "@/utils/nostr/nostr-helper-functions";
import { NostrEvent } from "../utils/types/types";
import { ProductContext, FollowsContext } from "../utils/context/context";
Expand All @@ -19,6 +20,9 @@ import {
import { getListingSlug } from "@/utils/url-slugs";
import { productSatisfiesAllFilters } from "@/utils/parsers/product-filter-helpers";

const BLOCKED_MARKETPLACE_SELLER =
"3da2082b7aa5b76a8f0c134deab3f7848c3b5e3a3079c65947d88422b69c1755";

const DisplayProducts = ({
focusedPubkey,
selectedCategories,
Expand Down Expand Up @@ -48,7 +52,7 @@ const DisplayProducts = ({
const [showModal, setShowModal] = useState(false);

const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 42;
const itemsPerPage = 60;
const [filteredProducts, setFilteredProducts] = useState<ProductData[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [isInitialized, setIsInitialized] = useState(false);
Expand All @@ -58,6 +62,37 @@ const DisplayProducts = ({
const { nostr } = useContext(NostrContext);
const { signer, pubkey: userPubkey } = useContext(SignerContext);

const totalEvents = productEventContext?.totalEvents ?? 0;

const getServerFilters = (): FilterParams | null => {
if (wotFilter && followsContext.isLoading) {
return null;
}

let pubkeyFilter: string | string[] | undefined;
if (wotFilter) {
const followList = followsContext.followList ?? [];
pubkeyFilter = focusedPubkey
? followList.includes(focusedPubkey)
? focusedPubkey
: []
: followList;
} else if (focusedPubkey) {
pubkeyFilter = focusedPubkey;
}

return {
search: selectedSearch,
categories: Array.from(selectedCategories),
location: selectedLocation,
pubkey: pubkeyFilter,
excludePubkeys:
userPubkey === BLOCKED_MARKETPLACE_SELLER
? undefined
: [BLOCKED_MARKETPLACE_SELLER],
};
};

// Load saved page from session storage on mount
useEffect(() => {
if (typeof window !== "undefined") {
Expand Down Expand Up @@ -154,8 +189,7 @@ const DisplayProducts = ({
if (product.images.length === 0) return false;
if (product.contentWarning) return false;
if (
product.pubkey ===
"3da2082b7aa5b76a8f0c134deab3f7848c3b5e3a3079c65947d88422b69c1755" &&
product.pubkey === BLOCKED_MARKETPLACE_SELLER &&
userPubkey !== product.pubkey
) {
return false;
Expand All @@ -164,10 +198,12 @@ const DisplayProducts = ({
});

setFilteredProducts(filtered);
const newTotalPages = Math.max(
1,
Math.ceil(filtered.length / itemsPerPage)
);

// We compute the invalidCount mathematically in the main component scope
// to dynamically bind it for renders without extra states.
// However, for totalPages state, we must evaluate it here with the newly filtered length.
const totalOnServer = productEventContext.totalEvents;
const newTotalPages = Math.max(1, Math.ceil(totalOnServer / itemsPerPage));
setTotalPages(newTotalPages);

// Check if filter actually changed (not just from initialization)
Expand Down Expand Up @@ -202,18 +238,77 @@ const DisplayProducts = ({
isInitialized,
]);

// Refresh products when filters change (server-side)
useEffect(() => {
if (!isInitialized) return;
const filters = getServerFilters();
if (!filters) return;

if (Array.isArray(filters.pubkey) && filters.pubkey.length === 0) {
productEventContext.setProductEvents([], 0);
return;
}

productEventContext.refreshProducts(filters);
}, [
Comment on lines +241 to +253
selectedSearch,
selectedCategories,
selectedLocation,
focusedPubkey,
isInitialized,
wotFilter,
followsContext.isLoading,
followsContext.followList,
userPubkey,
]);

useEffect(() => {
const filters = getServerFilters();
if (!filters) return;
if (Array.isArray(filters.pubkey) && filters.pubkey.length === 0) return;

const productsNeeded = currentPage * itemsPerPage;
const totalOnServer = productEventContext.totalEvents;
const hasMoreOnServer =
productEventContext.productEvents &&
productEventContext.productEvents.length < totalOnServer;

if (
!productEventContext.isLoading &&
hasMoreOnServer &&
filteredProducts.length < productsNeeded
) {
productEventContext.loadMoreProducts(filters);
}
}, [
currentPage,
productEventContext.isLoading,
productEventContext.totalEvents,
productEventContext.productEvents?.length,
filteredProducts.length,
itemsPerPage,
selectedSearch,
selectedCategories,
selectedLocation,
focusedPubkey,
wotFilter,
followsContext.isLoading,
followsContext.followList,
userPubkey,
]);

// Scroll effect only on page change
useEffect(() => {
// Skip initial render (currentPage === 1)
if (currentPage === 1) return;

const timer = requestAnimationFrame(() => {
if (searchBarRef?.current) {
searchBarRef.current.scrollIntoView({
behavior: "smooth",
block: "start",
});
window.scrollBy(0, -80); // Adjust for fixed header
const y =
searchBarRef.current.getBoundingClientRect().top +
window.scrollY -
80;
window.scrollTo({ top: Math.max(0, y), behavior: "smooth" });
} else {
window.scrollTo({
top: 0,
Expand Down Expand Up @@ -328,10 +423,17 @@ const DisplayProducts = ({
</div>
)}

<div className="text-light-text dark:text-dark-text mt-2 mb-6 text-center text-xs">
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
{Math.min(filteredProducts.length, currentPage * itemsPerPage)} of{" "}
{filteredProducts.length} products
<div className="text-light-text dark:text-dark-text mt-2 mb-6 flex flex-col items-center text-center text-xs">
<div>
Showing {(currentPage - 1) * itemsPerPage + 1} to{" "}
{Math.min(totalEvents, currentPage * itemsPerPage)} of{" "}
{totalEvents} products
Comment on lines +428 to +430
</div>
{productEventContext.isLoading && (
<div className="mt-2 text-purple-500">
Loading more pages...
</div>
)}
</div>
</>
)}
Expand Down
Loading