|
|
@@ -1,9 +1,9 @@
|
|
|
"use client";
|
|
|
|
|
|
-import { useState, useEffect, useRef } from "react";
|
|
|
+import { useState, useEffect, useRef,useCallback,useMemo } from "react";
|
|
|
|
|
|
import Grid from "@components/theme/ui/grid/Grid";
|
|
|
-import ProductGridItems from "@components/catalog/product/ProductGridItems";
|
|
|
+import NewProductGridItems from "@components/catalog/product/NewProductGridItems";
|
|
|
import NewMobileFilter from "@components/theme/filters/NewMobileFilter";
|
|
|
import NewSortOrder from "@components/theme/filters/NewSortOrder";
|
|
|
|
|
|
@@ -23,6 +23,7 @@ interface QueryState {
|
|
|
|
|
|
cursor?: string;
|
|
|
reverse?:boolean,
|
|
|
+ page: number,
|
|
|
}
|
|
|
interface ProductListingProps {
|
|
|
slug:string;
|
|
|
@@ -57,74 +58,51 @@ export default function ProductListing({
|
|
|
const [query, setQuery] = useState(initialQuery);
|
|
|
|
|
|
const [products, setProducts] = useState(initialProducts);
|
|
|
-
|
|
|
+ const [isLoading, setIsLoading] = useState(false);
|
|
|
const [pageInfo, setPageInfo] = useState(initialPageInfo);
|
|
|
-
|
|
|
const [_total, setTotal] = useState(initialTotal);
|
|
|
+ const currentPage = query.page ?? 1;
|
|
|
+ const perPage = 8;
|
|
|
+ const totalPages = Math.ceil(_total / perPage);
|
|
|
const firstRender = useRef(true);
|
|
|
const apolloClient = useApolloClient();
|
|
|
-
|
|
|
+ const [pageInputVal, setPageInputVal] = useState(String(currentPage));
|
|
|
const fetchProducts = async (currentQuery: QueryState) => {
|
|
|
+ if (isLoading) return; // ✅ 防重复请求:请求进行中直接返回
|
|
|
try {
|
|
|
- // 转换
|
|
|
- const finalFilterObj = transformFiltersForApi(currentQuery.filters);
|
|
|
+ setIsLoading(true); // 打开商品区遮罩
|
|
|
+ const finalFilterObj = transformFiltersForApi(currentQuery.filters);
|
|
|
const res = await apolloClient.query<CategoryProductsResult>({
|
|
|
query: CATEGORY_PRODUCTS,
|
|
|
- variables:
|
|
|
- // {
|
|
|
-
|
|
|
- // filter: JSON.stringify({
|
|
|
- // }),
|
|
|
- // first: 15,
|
|
|
- // after: null,
|
|
|
- // }
|
|
|
- {
|
|
|
- slug: slug ||"ready-to-go-wig",
|
|
|
-
|
|
|
+ variables: {
|
|
|
+ slug: slug || "ready-to-go-wig",
|
|
|
sortKey: currentQuery.sortKey,
|
|
|
reverse: currentQuery.reverse,
|
|
|
- filter:JSON.stringify( finalFilterObj),
|
|
|
-
|
|
|
- first: 15,
|
|
|
-
|
|
|
- after: null,
|
|
|
+ filter: JSON.stringify(finalFilterObj),
|
|
|
+ first: 8,
|
|
|
+ page:currentQuery.page,
|
|
|
+ after:null
|
|
|
},
|
|
|
});
|
|
|
- console.log("res----------------------------------:",res,currentQuery,finalFilterObj);
|
|
|
-
|
|
|
- // const res = await fetch("/api/products", {
|
|
|
- // method: "POST",
|
|
|
- // headers: {
|
|
|
- // "Content-Type": "application/json",
|
|
|
- // },
|
|
|
- // body: JSON.stringify({
|
|
|
- // categoryId,
|
|
|
-
|
|
|
- // sort: currentQuery.sort,
|
|
|
-
|
|
|
- // filters: currentQuery.filters,
|
|
|
-
|
|
|
- // first: 12,
|
|
|
-
|
|
|
- // after: null,
|
|
|
- // }),
|
|
|
- // });
|
|
|
- // search: currentQuery.search,
|
|
|
+ console.log("res----------------------------------:", res, currentQuery, finalFilterObj);
|
|
|
console.log("筛选排序触发调接口");
|
|
|
- // const result = await res.json();
|
|
|
-
|
|
|
+
|
|
|
const newProducts =
|
|
|
res.data?.categoryProducts?.products.map((e: any) => e.node) || [];
|
|
|
-
|
|
|
setProducts(newProducts);
|
|
|
- if(res.data?.categoryProducts?.pageInfo){
|
|
|
- setPageInfo(res.data.categoryProducts.pageInfo);
|
|
|
+ if (res.data?.categoryProducts?.pageInfo) {
|
|
|
+ setPageInfo(res.data.categoryProducts.pageInfo);
|
|
|
}
|
|
|
setTotal(res.data?.categoryProducts?.totalCount || 0);
|
|
|
} catch (error) {
|
|
|
console.error("fetch products error", error);
|
|
|
+ } finally {
|
|
|
+ setIsLoading(false); // ✅ finally:无论成功失败都关闭遮罩
|
|
|
}
|
|
|
};
|
|
|
+useEffect(() => {
|
|
|
+ setPageInputVal(String(currentPage));
|
|
|
+}, [currentPage]);
|
|
|
useEffect(() => {
|
|
|
if (firstRender.current) {
|
|
|
firstRender.current = false;
|
|
|
@@ -135,14 +113,14 @@ export default function ProductListing({
|
|
|
console.log("query-----------------------------22222:",query);
|
|
|
fetchProducts(query);
|
|
|
|
|
|
- }, [query.filters, query.sortValue]);
|
|
|
+ }, [query.filters, query.sortValue,query.page]);
|
|
|
//query.search
|
|
|
const router = useRouter();
|
|
|
const pathname = usePathname();
|
|
|
console.log("query-----:", query);
|
|
|
const updateQuery = (nextQuery: any) => {
|
|
|
+ console.log("==== updateQuery 被调用,新query:", nextQuery, new Error().stack);
|
|
|
setQuery(nextQuery);
|
|
|
-
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
|
if (nextQuery.search) {
|
|
|
@@ -153,107 +131,190 @@ export default function ProductListing({
|
|
|
params.set("sort", nextQuery.sortValue);
|
|
|
}
|
|
|
|
|
|
- Object.entries(nextQuery.filters).forEach(([key, value]) => {
|
|
|
+ Object.entries(nextQuery.filters || {}).forEach(([key, value]) => {
|
|
|
if (value) {
|
|
|
params.set(key, value as string);
|
|
|
}
|
|
|
});
|
|
|
-
|
|
|
+ if (nextQuery.page && nextQuery.page > 1) {
|
|
|
+ params.set("page", String(nextQuery.page));
|
|
|
+ } else {
|
|
|
+ params.delete("page"); // page<=1,删掉page参数,URL干净
|
|
|
+ }
|
|
|
router.replace(`${pathname}?${params.toString()}`, {
|
|
|
scroll: false,
|
|
|
});
|
|
|
};
|
|
|
- // view more 逻辑
|
|
|
- const loadMore = async () => {
|
|
|
- // const res = await fetch("/api/products", {
|
|
|
- // method: "POST",
|
|
|
-
|
|
|
- // headers: {
|
|
|
- // "Content-Type": "application/json",
|
|
|
- // },
|
|
|
-
|
|
|
- // body: JSON.stringify({
|
|
|
- // categoryId,
|
|
|
- // sort: query.sort,
|
|
|
-
|
|
|
- // filters: query.filters,
|
|
|
-
|
|
|
- // first: 15,
|
|
|
-
|
|
|
- // after: pageInfo.endCursor,
|
|
|
- // }),
|
|
|
- // });
|
|
|
- const res = await apolloClient.query<CategoryProductsResult>({
|
|
|
- query: CATEGORY_PRODUCTS,
|
|
|
- variables:
|
|
|
- // {
|
|
|
-
|
|
|
- // filter: JSON.stringify({
|
|
|
- // }),
|
|
|
- // first: 15,
|
|
|
- // after: null,
|
|
|
- // }
|
|
|
- {
|
|
|
- slug: slug ||"ready-to-go-wig",
|
|
|
-
|
|
|
- // sort: query.sort,
|
|
|
- sortKey: query.sortKey,
|
|
|
- reverse: query.reverse,
|
|
|
- filter:JSON.stringify(query.filters),
|
|
|
-
|
|
|
- first: 15,
|
|
|
-
|
|
|
- after: pageInfo.endCursor,
|
|
|
- },
|
|
|
- });
|
|
|
- //search: query.search,
|
|
|
- console.log("触发view more调接口");
|
|
|
- console.log("res----------------------------------:",res,query);
|
|
|
- // const result = await res.json();
|
|
|
-
|
|
|
- const moreProducts =
|
|
|
- res.data?.categoryProducts?.products.map((e: any) => e.node) || [];
|
|
|
-
|
|
|
- setProducts((prev) => [...prev, ...moreProducts]);
|
|
|
- if(res.data?.categoryProducts?.pageInfo){
|
|
|
- setPageInfo(res.data?.categoryProducts?.pageInfo);
|
|
|
+ // 对比筛选对象
|
|
|
+ function isFiltersEqual(a: Record<string, string>, b: Record<string, string>) {
|
|
|
+ const keysA = Object.keys(a);
|
|
|
+ const keysB = Object.keys(b);
|
|
|
+ if (keysA.length !== keysB.length) return false;
|
|
|
+ for (const key of keysA) {
|
|
|
+ if (a[key] !== b[key]) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 筛选排序回调
|
|
|
+ const handleFilterChange = useCallback((filters: Record<string, string>) => {
|
|
|
+ //对比避免重复渲染导致分页初始化一直 为1
|
|
|
+ if (isFiltersEqual(filters, query.filters)) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ updateQuery({
|
|
|
+ ...query,
|
|
|
+ filters,
|
|
|
+ page: 1,
|
|
|
+ });
|
|
|
+ }, [query]);
|
|
|
+ const handleSortChange = useCallback((sortValue:string, sortKey:string, reverse:boolean) => {
|
|
|
+ updateQuery({
|
|
|
+ ...query,
|
|
|
+ sortValue,
|
|
|
+ sortKey,
|
|
|
+ reverse,
|
|
|
+ page: 1,
|
|
|
+ });
|
|
|
+ }, [query]);
|
|
|
+ // 分页逻辑
|
|
|
+ function generatePageNumbers(current: number, total: number) {
|
|
|
+ const pages: number[] = [];
|
|
|
+ const displayCount = 5; // 固定展示5个页码
|
|
|
+
|
|
|
+ if (total <= displayCount) {
|
|
|
+
|
|
|
+ for (let i = 1; i <= total; i++) {
|
|
|
+ pages.push(i);
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ let start: number;
|
|
|
+ if (current <= 3) {
|
|
|
+ start = 1;
|
|
|
+ }
|
|
|
+ else if (current >= total - 2) {
|
|
|
+ start = total - displayCount + 1;
|
|
|
}
|
|
|
+ else {
|
|
|
+ start = current - 2;
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = start; i < start + displayCount; i++) {
|
|
|
+ pages.push(i);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return pages;
|
|
|
+ }
|
|
|
+ // 切换页码,复用你现有的 updateQuery
|
|
|
+ const handlePageChange = (page: number) => {
|
|
|
+ if (page < 1 || page > totalPages) return;
|
|
|
+ updateQuery({
|
|
|
+ ...query,
|
|
|
+ page: page,
|
|
|
+ // 分页切换不需要重置filters/sort,只修改page
|
|
|
+ });
|
|
|
};
|
|
|
+
|
|
|
+ // 输入框跳转页码
|
|
|
+ const handleJumpPage = (val: string) => {
|
|
|
+ const target = Number(val);
|
|
|
+ if (!Number.isInteger(target)) return;
|
|
|
+ if (target < 1 || target > totalPages) return;
|
|
|
+ handlePageChange(target);
|
|
|
+ };
|
|
|
+ const pageList = useMemo(() => {
|
|
|
+ return generatePageNumbers(currentPage, totalPages);
|
|
|
+ }, [currentPage, totalPages]);
|
|
|
return (
|
|
|
<>
|
|
|
- <div className="flex justify-between py-8 w-full mx-auto ">
|
|
|
+ <div className="flex justify-between py-8 w-full mx-auto max-w-[1824px] gap-12">
|
|
|
<NewMobileFilter
|
|
|
filterAttributes={filterAttributes}
|
|
|
filters={query.filters}
|
|
|
- onChange={(filters) => {
|
|
|
- updateQuery({
|
|
|
- ...query,
|
|
|
-
|
|
|
- filters,
|
|
|
-
|
|
|
- cursor: undefined,
|
|
|
- });
|
|
|
- }}
|
|
|
+ onChange={handleFilterChange}
|
|
|
/>
|
|
|
<div>
|
|
|
<NewSortOrder
|
|
|
sortOrders={newSortByFields}
|
|
|
title="Sort by"
|
|
|
value={query.sortValue}
|
|
|
- onChange={(sortValue,sortKey,reverse) => {
|
|
|
- updateQuery({
|
|
|
- ...query,
|
|
|
- sortValue,
|
|
|
- sortKey,
|
|
|
- reverse:reverse,
|
|
|
- cursor: undefined,
|
|
|
- });
|
|
|
- }}
|
|
|
+ onChange={handleSortChange}
|
|
|
/>
|
|
|
{isArray(products) && products.length > 0 ? (
|
|
|
- <Grid className="grid grid-cols-4 gap-8">
|
|
|
- <ProductGridItems products={products} />
|
|
|
+ <div className="relative">
|
|
|
+ <Grid className="grid grid-cols-4 gap-8 min-h-[760px]">
|
|
|
+ <NewProductGridItems products={products} />
|
|
|
</Grid>
|
|
|
+ {isLoading && (
|
|
|
+ <div className="absolute inset-0 z-20 bg-white/70 dark:bg-black/50 flex items-center justify-center rounded-lg">
|
|
|
+ <div className="w-10 h-10 border-4 border-neutral-200 border-t-neutral-800 dark:border-t-white rounded-full animate-spin"></div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <div className="flex items-center justify-end gap-3 pt-6 shrink-0">
|
|
|
+ <button
|
|
|
+ onClick={() => handlePageChange(currentPage - 1)}
|
|
|
+ disabled={currentPage === 1}
|
|
|
+ className={`px-1 py-1 ${currentPage <=1 ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
|
|
+ >
|
|
|
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
+ <polyline points="15 18 9 12 15 6"></polyline>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ {pageList.map(p=> (
|
|
|
+ <button
|
|
|
+ key={p}
|
|
|
+ onClick={() => handlePageChange(Number(p))}
|
|
|
+ className={`w-9 h-9 rounded-sm text-base transition-colors
|
|
|
+ ${currentPage === p
|
|
|
+ ? "bg-[#0F6636] text-white"
|
|
|
+ : "hover:bg-gray-100 text-gray-700"}
|
|
|
+ `}
|
|
|
+ >
|
|
|
+ {p}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+
|
|
|
+ {/* 下一页 */}
|
|
|
+ <button
|
|
|
+ onClick={() => handlePageChange(currentPage + 1)}
|
|
|
+ disabled={currentPage >= totalPages}
|
|
|
+ className={`px-1 py-1 ${currentPage >= totalPages ? "opacity-40 cursor-not-allowed" : "cursor-pointer"}`}
|
|
|
+ >
|
|
|
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
+ <polyline points="9 18 15 12 9 6"></polyline>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+
|
|
|
+
|
|
|
+ <div className="flex items-center ml-4">
|
|
|
+ <button
|
|
|
+ onClick={() => handlePageChange(currentPage - 1)}
|
|
|
+ disabled={currentPage <=1}
|
|
|
+ className={`px-1 ${currentPage <=1 ? "opacity-40" : ""}`}
|
|
|
+ >
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
+ <polyline points="15 18 9 12 15 6"></polyline>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ <input
|
|
|
+ value={pageInputVal}
|
|
|
+ onChange={(e)=>setPageInputVal(e.target.value)}
|
|
|
+ onKeyDown={(e)=> e.key === 'Enter' && handleJumpPage(e.currentTarget.value)}
|
|
|
+ className="w-9 h-9 border border-gray-300 rounded-md text-center outline-none focus:border-[#0F6636]"
|
|
|
+ type="text"
|
|
|
+ />
|
|
|
+ <span className="mx-1">/ {totalPages}</span>
|
|
|
+ <button
|
|
|
+ onClick={() => handlePageChange(currentPage + 1)}
|
|
|
+ disabled={currentPage >= totalPages}
|
|
|
+ className={`px-1 ${currentPage >= totalPages ? "opacity-40" : ""}`}
|
|
|
+ >
|
|
|
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
+ <polyline points="9 18 15 12 9 6"></polyline>
|
|
|
+ </svg>
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
) : (
|
|
|
<div className="px-4">
|
|
|
<div className="flex h-40 items-center justify-center rounded-lg border border-dashed border-neutral-300">
|
|
|
@@ -263,11 +324,6 @@ export default function ProductListing({
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
- {pageInfo?.hasNextPage && (
|
|
|
- <div className="flex justify-center my-10">
|
|
|
- <button onClick={loadMore}>View More</button>
|
|
|
- </div>
|
|
|
- )}
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|