Преглед на файлове

分类产品列表页调整

zhangzf преди 10 часа
родител
ревизия
e94f8101d8

+ 3 - 3
src/app/(public)/category/[slug]/[id]/_components/CategoryDesc.tsx

@@ -41,19 +41,19 @@ const CategoryDesc: FC<CategoryDescProps> = ({ title, description }) => {
   );
 
   return (
-    <div className="">
+    <div className="max-w-[1824px] m-auto">
       <h2 className="text-ly-24 leading-ly-26 font-medium mt-8 mb-4">
         {title}
       </h2>
 
-      <p
+      <div
         ref={textRef}
         className={`overflow-hidden text-ly-18 leading-ly-26 font-normal transition-[max-height] text-[#666] duration-300 ease-in-out ${
           isExpand ? "" : "line-clamp-2"
         }`}
       >
         <div dangerouslySetInnerHTML={{ __html: description }} />
-      </p>
+      </div>
 
       {showMore && (
         <button

+ 33 - 26
src/app/(public)/category/[slug]/[id]/_components/FaqList.tsx

@@ -42,17 +42,17 @@ function FaqAccordionItem({
   );
 
   // 点赞爱心SVG提取
-  const LikeIcon = (
-    <svg
-      xmlns="http://www.w3.org/2000/svg"
-      width="16"
-      height="16"
-      fill="currentColor"
-      viewBox="0 0 16 16"
-    >
-      <path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z" />
-    </svg>
-  );
+  // const LikeIcon = (
+  //   <svg
+  //     xmlns="http://www.w3.org/2000/svg"
+  //     width="16"
+  //     height="16"
+  //     fill="currentColor"
+  //     viewBox="0 0 16 16"
+  //   >
+  //     <path d="M8 1.314C12.438-3.248 23.534 4.735 8 15-7.534 4.736 3.562-3.248 8 1.314z" />
+  //   </svg>
+  // );
 
   return (
     <div className="border border-gray-100 rounded-lg bg-[#F9F9F9] p-4 mb-3">
@@ -60,7 +60,7 @@ function FaqAccordionItem({
       <button
         type="button"
         onClick={onToggle}
-        className="w-full flex items-start justify-between text-left gap-3"
+        className="w-full flex items-center justify-between text-left gap-3"
       >
         {/* 绿色圆形Q图标 */}
         <div className="w-8 h-8 rounded-full bg-emerald-700 flex items-center justify-center shrink-0 text-white">
@@ -68,7 +68,7 @@ function FaqAccordionItem({
         </div>
 
         {/* 问题文字 */}
-        <h3 className="text-base font-medium text-emerald-800 flex-1">
+        <h3 className="text-base font-medium leading-ly-22 text-emerald-800 flex-1">
           {item.question}
         </h3>
 
@@ -84,15 +84,15 @@ function FaqAccordionItem({
             <div className="w-8 h-8 rounded-full bg-white border border-gray-900 flex items-center justify-center shrink-0">
               A
             </div>
-            <p className="text-gray-800 text-sm leading-relaxed">
+            <p className="text-gray-800 text-sm leading-ly-22">
               {item.answer}
             </p>
           </div>
           {/* 点赞栏 */}
-          <div className="flex items-center justify-end gap-2 text-emerald-700 text-sm">
+          {/* <div className="flex items-center justify-end gap-2 text-emerald-700 text-sm">
             <span>{item.likeCount}</span>
             {LikeIcon}
-          </div>
+          </div> */}
         </div>
       )}
     </div>
@@ -113,16 +113,23 @@ export default function FaqList({ faqList }: { faqList: FaqItem[] }) {
   };
 
   return (
-    <div className="w-full max-w-2xl mx-auto">
-      <div className="space-y-1 mt-4 px-4">
-        {displayList.map((item) => (
-          <FaqAccordionItem
-            key={item.id}
-            item={item}
-            isOpen={openId === item.id}
-            onToggle={() => toggleFaq(item.id)}
-          />
-        ))}
+    <div className="w-full max-w-[1824px] m-auto  flex justify-between gap-12">
+      <div className="w-full max-w-[316px]">
+
+      </div>
+      <div className="w-full">
+          <h1 className="mt-1 text-ly-18 font-medium leading-ly-26">FQA</h1>
+          <div className="w-full h-px my-6 bg-black"></div>
+          <div className="space-y-1 mt-4 ">
+            {displayList.map((item) => (
+              <FaqAccordionItem
+                key={item.id}
+                item={item}
+                isOpen={openId === item.id}
+                onToggle={() => toggleFaq(item.id)}
+              />
+            ))}
+          </div>
       </div>
     </div>
   );

+ 188 - 132
src/app/(public)/category/[slug]/[id]/_components/ProductListing.tsx

@@ -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>
     </>

+ 8 - 46
src/app/(public)/category/[slug]/[id]/page.tsx

@@ -162,7 +162,10 @@ export default async function CategoryPage({
   const before = getSearchParam(resolvedParams?.before);
   const itemsPerPage = 8;
   // const currentPage = page ? parseInt(page) - 1 : 0;
-  const sortValue = getSearchParam(resolvedParams?.sort) ?? "name-asc";
+  const pageParam =  getSearchParam(resolvedParams?.page);
+  const initialPage = pageParam ? Number(pageParam)  : 1;
+   debugger
+  const sortValue = getSearchParam(resolvedParams?.sort) ?? "featured";
   const selectedSort =
     newSortByFields.find((s) => s.key === sortValue) || newSortByFields[0];
 
@@ -188,56 +191,13 @@ export default async function CategoryPage({
     variables: {
       slug: _categorySlug || "Ready To Go Wig",
       filter: filterInput,
-      id: Number(categoryId),
-      ...(before
-        ? {
-            last: itemsPerPage,
-            before,
-          }
-        : {
-            first: itemsPerPage,
-            after: cursor || null,
-          }),
+      after:null,
+      page:initialPage,
       sortKey: selectedSort.sortKey,
       reverse: selectedSort.reverse,
     },
   });
   console.log("categoryProductDatas-----------:",categoryProductDatas,selectedSort);
-  
-  // 这个也改成 serverGraphqlFetch
-  // const productVariables = {
-  //   query: searchValue || "",
-  //   filter: filterInput,
-  //   ...(before
-  //     ? {
-  //         last: itemsPerPage,
-  //         before,
-  //       }
-  //     : {
-  //         first: itemsPerPage,
-  //         after: cursor || null,
-  //       }),
-  //   sortKey: selectedSort.sortKey,
-  //   reverse: selectedSort.reverse,
-  // };
-  // const { data } = await serverGraphqlFetch<
-  //   ProductsResponse,
-  //   typeof productVariables
-  // >({
-  //   query: GET_FILTER_PRODUCTS,
-  //   variables: productVariables,
-  // });
-  // const [{ data }] = await Promise.all([
-  //   cachedCategoryRequest<ProductsResponse>(categorySlug, GET_FILTER_PRODUCTS, {
-  //     query: searchValue || "",
-  //     filter: filterInput,
-  //     ...(before
-  //       ? { last: itemsPerPage, before: before }
-  //       : { first: itemsPerPage, after: cursor }),
-  //     sortKey: selectedSort.sortKey,
-  //     reverse: selectedSort.reverse,
-  //   }),
-  // ]);
   console.log(
     "GET_FILTER_PRODUCTS ----- ",
     searchValue,
@@ -258,6 +218,7 @@ export default async function CategoryPage({
     sortKey: selectedSort.sortKey,
     reverse: selectedSort.reverse,
     filters: baseFilterObject,
+    page: initialPage,
   });
   // 模拟FAQ假数据
   const mockFaqData: FaqItem[] = [
@@ -343,6 +304,7 @@ export default async function CategoryPage({
             sortKey: selectedSort.sortKey,
             reverse: selectedSort.reverse,
             filters: baseFilterObject,
+            page: initialPage,
             cursor,
           }}
         />

+ 129 - 0
src/components/catalog/product/NewProductGridItems.tsx

@@ -0,0 +1,129 @@
+import { baseUrl, getImageUrl, NOT_IMAGE } from "@/utils/constants";
+import Grid from "@/components/theme/ui/grid/Grid";
+import { OpenAddToCartModalButton } from "@/components/common/AddToCartModal/OpenAddToCartModalButton";
+import Image from "next/image";
+import Link from "next/link";
+export default function NewProductGridItems({
+  products,
+}: {
+  products: any;
+}) {
+  return products.map((product: any, index: number) => {
+
+    const imageUrl = getImageUrl(product?.baseImageUrl, baseUrl, NOT_IMAGE);
+    const price =
+      product?.type === "configurable"
+        ? product?.minimumPrice ?? "0"
+        : product?.price ?? "0";
+    // const currency = product?.priceHtml?.currencyCode;
+    return (
+            <Grid.Item key={product.id} className="flex flex-col ">
+                        {/* 产品图片 */}
+                        <Image
+                          src={imageUrl}
+                          alt={product.name}
+                          width={339}
+                          height={452}
+                          sizes="(max-width: 768px) 50vw, (max-width: 1200px) 33vw, 25vw"
+                          className=" bg-neutral-100 object-cover transition duration-300 ease-in-out group-hover:scale-105"
+                        />
+                        <Link href={`/${product.urlKey}.html`} className="text-sm text-gray-800 truncate mt-4 mb-2" aria-label={`View ${product.name}`}>
+                          {product.name}
+                        </Link>
+                        {/* 评分 + 评论数 */}
+                        {product.review_count ? (
+                          <div className="flex items-center gap-1">
+                            <div className="flex">
+                              {/* 星级图标(简化版,可替换为真实星星图标) */}
+                              {[1, 2, 3, 4, 5].map((star) => (
+                                <svg
+                                  key={star}
+                                  className="w-4 h-4 text-yellow-400"
+                                  fill="currentColor"
+                                  viewBox="0 0 20 20"
+                                >
+                                  <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
+                                </svg>
+                              ))}
+                            </div>
+                            <span className="text-xs text-gray-600">
+                              {product.review_count}
+                            </span>
+                          </div>
+                        ) : null}
+                        {/* 价格 + 购物车图标 */}
+                        <div className="flex items-center mt-4 justify-between">
+                          <div className="flex items-center gap-1">
+                            <span className="font-semibold">${price}</span>
+                            <span className="text-xs text-gray-500 line-through">
+                              ${product.price}
+                            </span>
+                          </div>
+                          {/* 购物车图标 */}
+                          <OpenAddToCartModalButton
+                              productId={product.id}
+                            icon={
+                              <svg
+                                xmlns="http://www.w3.org/2000/svg"
+                                width="32"
+                                height="32"
+                                viewBox="0 0 32 32"
+                                fill="none"
+                              >
+                                <rect
+                                  x="0"
+                                  y="0"
+                                  width="32"
+                                  height="32"
+                                  rx="8"
+                                  fill="#036141"
+                                ></rect>
+                                <path
+                                  stroke="rgba(255, 255, 255, 1)"
+                                  stroke-width="1.3333333333333333"
+                                  stroke-linejoin="round"
+                                  stroke-linecap="round"
+                                  d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
+                                ></path>
+                                <ellipse
+                                  cx="11.066040515899658"
+                                  cy="23.199928283691406"
+                                  rx="1.4000248908996582"
+                                  ry="1.4000015258789062"
+                                  stroke="rgba(255, 255, 255, 1)"
+                                  stroke-width="1.3333333333333333"
+                                  stroke-linejoin="round"
+                                  stroke-linecap="round"
+                                ></ellipse>
+                                <ellipse
+                                  cx="23.1998291015625"
+                                  cy="23.199928283691406"
+                                  rx="1.4000244140625"
+                                  ry="1.4000015258789062"
+                                  stroke="rgba(255, 255, 255, 1)"
+                                  stroke-width="1.3333333333333333"
+                                  stroke-linejoin="round"
+                                  stroke-linecap="round"
+                                ></ellipse>
+                                <path
+                                  stroke="rgba(255, 255, 255, 1)"
+                                  stroke-width="1.3333333333333333"
+                                  stroke-linejoin="round"
+                                  stroke-linecap="round"
+                                  d="M15.2656 15.2665L18.999 15.2665"
+                                ></path>
+                                <path
+                                  stroke="rgba(255, 255, 255, 1)"
+                                  stroke-width="1.3333333333333333"
+                                  stroke-linejoin="round"
+                                  stroke-linecap="round"
+                                  d="M17.1348 17.1332L17.1348 13.3999"
+                                ></path>
+                              </svg>
+                            }
+                          />
+                        </div>
+            </Grid.Item>
+    );
+  });
+}

+ 126 - 74
src/components/theme/filters/NewMobileFilter.tsx

@@ -1,6 +1,5 @@
 "use client";
-import { Button } from "@heroui/button";
-import { useEffect, useState, useRef } from "react";
+import { useEffect, useState, useMemo, useRef } from "react";
 import FilterPrice from "./FilterPrice";
 
 interface MobileFilterProps {
@@ -8,56 +7,104 @@ interface MobileFilterProps {
   filters: Record<string, string>;
   onChange: (filters: Record<string, string>) => void;
 }
+
+const DEBOUNCE_MS = 300;
+
 export default function MobileFilter({
   filterAttributes,
   filters,
   onChange,
 }: MobileFilterProps) {
-  /**
-   * 临时筛选状态
-   */
   const [tempFilters, setTempFilters] = useState<Record<string, string>>({});
-  const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
-    {},
-  );
+  const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
+
+  const isFirstMount = useRef(true);
+  const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
+  // 保存最新onChange,移出effect依赖
+  const onChangeRef = useRef(onChange);
+
+  useEffect(() => {
+    onChangeRef.current = onChange;
+  }, [onChange]);
+
+  const sortedFilterAttributes = useMemo(() => {
+    const priceAttr = filterAttributes.find((item) => item.node.code === "price");
+    const restAttrs = filterAttributes.filter((item) => item.node.code !== "price");
+    return [...restAttrs, priceAttr].filter(Boolean);
+  }, [filterAttributes]);
 
-  // 父组件filters更新时同步到本地临时筛选
+  // ✅ 同步父组件filters到本地,增加对比:内容不变就跳过setState,打断循环
   useEffect(() => {
+    // 浅对比:内容完全一样,直接return,不更新tempFilters
+    if (JSON.stringify(tempFilters) === JSON.stringify(filters)) {
+      return;
+    }
+
+    if (debounceTimer.current) {
+      clearTimeout(debounceTimer.current);
+      debounceTimer.current = null;
+    }
     setTempFilters({ ...filters });
+
     const expand: Record<string, boolean> = {};
-    filterAttributes.forEach((attr) => {
-      expand[attr.code] = true;
+    sortedFilterAttributes.forEach((item) => {
+      expand[item.node.code] = true;
     });
     setExpandedGroups(expand);
-  }, [filters, filterAttributes]);
-
-  /**
-   * 多选切换
-   */
- const toggleOption = (code: string, id: string) => {
-  setTempFilters((prev) => {
-    const currentStr = prev[code] ?? "";
-    const selectedSet = new Set(currentStr ? currentStr.split(",") : []);
-    // debugger
-    if (selectedSet.has(id)) {
-      // 已选中 → 删除
-      selectedSet.delete(id);
-    } else {
-      // 未选中 → 添加
-      selectedSet.add(id);
+  }, [filters, sortedFilterAttributes]);
+
+  // 组件卸载清理定时器
+  useEffect(() => {
+    return () => {
+      if (debounceTimer.current) {
+        clearTimeout(debounceTimer.current);
+      }
+    };
+  }, []);
+
+  // ✅ 防抖,只监听 tempFilters,不再依赖 onChange
+  useEffect(() => {
+    if (isFirstMount.current) {
+      isFirstMount.current = false;
+      return;
     }
 
-    const next = { ...prev };
-    if (selectedSet.size === 0) {
-      delete next[code];
-    } else {
-      next[code] = Array.from(selectedSet).join(",");
+    if (debounceTimer.current) {
+      clearTimeout(debounceTimer.current);
     }
-    console.log("【点击选项后临时筛选tempFilters】", next);
-    return next;
-  });
-};
 
+    debounceTimer.current = setTimeout(() => {
+      console.log("✅防抖执行,调用onChange", tempFilters);
+      onChangeRef.current(tempFilters);
+    }, DEBOUNCE_MS);
+
+    return () => {
+      if (debounceTimer.current) {
+        clearTimeout(debounceTimer.current);
+      }
+    };
+  }, [tempFilters]);
+
+  const toggleOption = (code: string, id: string) => {
+    setTempFilters((prev) => {
+      const currentStr = prev[code] ?? "";
+      const selectedSet = new Set(currentStr ? currentStr.split(",") : []);
+
+      if (selectedSet.has(id)) {
+        selectedSet.delete(id);
+      } else {
+        selectedSet.add(id);
+      }
+
+      const next = { ...prev };
+      if (selectedSet.size === 0) {
+        delete next[code];
+      } else {
+        next[code] = Array.from(selectedSet).join(",");
+      }
+      return next;
+    });
+  };
 
   const toggleGroupExpand = (code: string) => {
     setExpandedGroups((prev) => ({
@@ -66,19 +113,8 @@ export default function MobileFilter({
     }));
   };
 
-  /**
-   * 应用筛选
-   */
-  const applyFilters = () => {
-    onChange(tempFilters);
-  };
-
-  /**
-   * 清空筛选
-   */
   const clearAll = () => {
     setTempFilters({});
-    onChange({});
   };
 
   const formatLabel = (str?: string) => {
@@ -86,17 +122,18 @@ export default function MobileFilter({
   };
 
   return (
-    <div className=" rounded-lg  w-full max-w-[300px]">
-      {/* Header */}
-      <div className="flex justify-between items-center pb-4 border-b">
+    <div className="rounded-lg w-full max-w-[316px]">
+      <div className="flex justify-between items-center pb-4 border-b border-b-[#ACBDB8] mr-4">
         <h2 className="text-xl font-semibold">Filters:</h2>
-        <button onClick={clearAll} className="text-sm text-primary">Clear All</button>
+        <button onClick={clearAll} className="text-sm font-medium leading-ly-22 text-black">
+          Clear All
+        </button>
       </div>
 
-      {/* 筛选内容区域 */}
-      <div className="py-4 max-h-[70vh] overflow-y-auto overflow-x-hidden">
-        {filterAttributes.map((attr) => {
+      <div className="py-4 max-h-[70vh] overflow-y-auto overflow-x-hidden pr-3">
+        {sortedFilterAttributes.map((attr) => {
           const expand = expandedGroups[attr.node.code] ?? true;
+
           return (
             <div key={attr.node._id} className="py-4 border-b">
               <button
@@ -104,8 +141,28 @@ export default function MobileFilter({
                 onClick={() => toggleGroupExpand(attr.node.code)}
               >
                 <span>{formatLabel(attr.node.adminName)}</span>
-                <span>⌄</span>
+                <span>
+                  <svg
+                    className={`transition-transform duration-300 ease ${
+                      expandedGroups[attr.node.code] ? "rotate-180" : "rotate-0"
+                    }`}
+                    xmlns="http://www.w3.org/2000/svg"
+                    width="24"
+                    height="24"
+                    viewBox="0 0 24 24"
+                    fill="none"
+                  >
+                    <path
+                      stroke="rgba(0, 0, 0, 1)"
+                      strokeWidth="1.2"
+                      strokeLinejoin="round"
+                      strokeLinecap="round"
+                      d="M18 9L12 15L6 9"
+                    ></path>
+                  </svg>
+                </span>
               </button>
+
               {expand && (
                 <div className="flex flex-col gap-3 mt-4">
                   {attr.node.code === "price" ? (
@@ -129,10 +186,12 @@ export default function MobileFilter({
                     />
                   ) : (
                     (attr.node.options.edges || []).map((opt: any) => {
-                        const selectedStr = tempFilters[attr.node.code] ?? "";
-                        const idStr = String(opt.node._id);
-                        const checked = selectedStr.split(",").includes(idStr);
-                        return opt.node.productCount > 0 && (
+                      const selectedStr = tempFilters[attr.node.code] ?? "";
+                      const idStr = String(opt.node._id);
+                      const checked = selectedStr.split(",").includes(idStr);
+
+                      return (
+                        opt.node.productCount > 0 && (
                           <label
                             key={opt.node._id}
                             className="flex gap-3 text-lg items-center"
@@ -140,11 +199,16 @@ export default function MobileFilter({
                             <input
                               type="checkbox"
                               checked={checked}
-                              onChange={() => toggleOption(attr.node.code, String(opt.node._id))}
+                              onChange={() =>
+                                toggleOption(attr.node.code, String(opt.node._id))
+                              }
                             />
-                            <span className="wrap-break-word">{opt.node.adminName}</span>
+                            <span className="wrap-break-word">
+                              {opt.node.adminName}
+                            </span>
                           </label>
-                        );
+                        )
+                      );
                     })
                   )}
                 </div>
@@ -153,18 +217,6 @@ export default function MobileFilter({
           );
         })}
       </div>
-
-      {/* 底部应用按钮 */}
-      <div className="">
-        <Button
-          color="primary"
-          size="lg"
-          className="w-full"
-          onPress={applyFilters}
-        >
-          Apply Filter
-        </Button>
-      </div>
     </div>
   );
 }

+ 75 - 57
src/components/theme/filters/NewSortOrder.tsx

@@ -1,23 +1,30 @@
 "use client";
-
-import { type FC } from "react";
-import {
-  Drawer,
-  DrawerContent,
-  DrawerHeader,
-  DrawerBody,
-} from "@heroui/drawer";
-import { Button } from "@heroui/button";
+import { type FC, useRef, useEffect, useCallback } from "react";
 import { useDisclosure } from "@heroui/use-disclosure";
-
 import { type SortOrderTypes } from "@/utils/constants";
-import { SortIcon } from "@components/common/icons/SortIcon";
+
+const ChevronDownIcon = ({ className = "" }: { className?: string }) => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="20"
+    height="20"
+    viewBox="0 0 24 24"
+    fill="none"
+    stroke="currentColor"
+    strokeWidth="2"
+    strokeLinecap="round"
+    strokeLinejoin="round"
+    className={className}
+  >
+    <path d="m6 9 6 6 6-6" />
+  </svg>
+);
 
 interface NewSortOrderProps {
   sortOrders: SortOrderTypes[];
   title: string;
   value: string;
-  onChange: (sortValue: string,sortKey: string,reverse:boolean) => void;
+  onChange: (sortValue: string, sortKey: string, reverse: boolean) => void;
 }
 
 const NewSortOrder: FC<NewSortOrderProps> = ({
@@ -26,60 +33,71 @@ const NewSortOrder: FC<NewSortOrderProps> = ({
   value,
   onChange,
 }) => {
-  const { isOpen, onOpen, onOpenChange } = useDisclosure();
+  const { isOpen, onOpen, onClose } = useDisclosure();
+  // 手动实现toggle切换函数
+  const toggleDropdown = useCallback(() => {
+    if (isOpen) {
+      onClose();
+    } else {
+      onOpen();
+    }
+  }, [isOpen, onOpen, onClose]);
+
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const activeOrder = sortOrders.find((item) => item.key === value);
+  const displayLabel = activeOrder?.title || sortOrders[0]?.title;
 
-  const handleSortChange = (sortValue: string,sortKey: string,reverse:boolean) => {
-    onChange(sortValue,sortKey,reverse);
-    onOpenChange();
+  const handleSortChange = (
+    sortValue: string,
+    sortKey: string,
+    reverse: boolean
+  ) => {
+    onChange(sortValue, sortKey, reverse);
+    onClose(); // 选中选项关闭下拉
   };
 
+  // 点击空白关闭
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+        if (isOpen) onClose();
+      }
+    };
+    document.addEventListener("mousedown", handleClickOutside);
+    return () => document.removeEventListener("mousedown", handleClickOutside);
+  }, [isOpen, onClose]);
+
   return (
-    <>
-      <div className="flex flex-wrap gap-3">
-        <Button
-          size="md"
-          variant="flat"
-          className="bg-neutral-100 dark:bg-neutral-800"
-          onPress={onOpen}
+    <div className="flex flex-wrap mb-6 justify-end">
+      <div ref={containerRef} className="relative inline-block">
+        {/* 点击调用toggleDropdown,实现展开/收起切换 */}
+        <button
+          type="button"
+          onClick={toggleDropdown}
+          className="inline-flex items-center justify-between w-full gap-1.5 text-base font-outfit tracking-wide bg-neutral-100 dark:bg-neutral-800 px-4 py-2 rounded-lg"
         >
-          <SortIcon />
-          <span className="font-outfit text-base tracking-wide">Sort</span>
-        </Button>
-      </div>
-
-      <Drawer
-        isOpen={isOpen}
-        placement="bottom"
-        onOpenChange={onOpenChange}
-        hideCloseButton
-      >
-        <DrawerContent className="rounded-t-[32px] relative">
-          {/* 右上角关闭叉号 匹配截图 */}
-          <button
-            onClick={() => onOpenChange()}
-            className="absolute right-5 top-6 text-2xl font-light z-10"
-          >
-            ×
-          </button>
+          <span>{displayLabel}</span>
+          <ChevronDownIcon
+            className={`transition-transform duration-300 ease ${
+              isOpen ? "rotate-180" : "rotate-0"
+            }`}
+          />
+        </button>
 
-          <DrawerHeader className="flex flex-col gap-1 pb-2 pt-2">
-            <div className="mx-auto h-1 w-10 rounded-full bg-neutral-300 mb-2" />
-            <h2 className="text-2xl font-bold">{title}</h2>
-          </DrawerHeader>
-
-          <DrawerBody className="px-0 pb-12">
-            {/* 列表容器,完全复刻截图样式 */}
+        {isOpen && (
+          <div className="absolute top-full left-0 mt-1 w-full bg-white dark:bg-neutral-800 rounded-lg shadow-lg z-30 ">
             <div className="flex flex-col">
               {sortOrders.map((order) => {
                 const isActive = value === order.key;
                 return (
                   <button
                     key={order.value}
-                    onClick={() => handleSortChange(order.key,order.sortKey,order.reverse)}
-                    className={`w-full text-center py-4 text-xl transition-colors ${
+                    onClick={() => handleSortChange(order.key, order.sortKey, order.reverse)}
+                    className={`w-full  px-4 py-3 text-base transition-colors text-center ${
                       isActive
-                        ? "bg-neutral-100 dark:bg-neutral-800 font-semibold"
-                        : "bg-transparent"
+                        ? "bg-neutral-100 dark:bg-neutral-700 font-semibold"
+                        : "hover:bg-neutral-50 dark:hover:bg-neutral-700/50"
                     }`}
                   >
                     {order.title}
@@ -87,10 +105,10 @@ const NewSortOrder: FC<NewSortOrderProps> = ({
                 );
               })}
             </div>
-          </DrawerBody>
-        </DrawerContent>
-      </Drawer>
-    </>
+          </div>
+        )}
+      </div>
+    </div>
   );
 };
 

+ 20 - 5
src/graphql/catalog/queries/GetCategoryProducts.ts

@@ -1,14 +1,29 @@
 import { gql } from "@apollo/client";
-
 export const CATEGORY_PRODUCTS = gql`
-query CategoryProducts($slug: String!, $filter: String, $sortKey: String, $reverse: Boolean, $first: Int, $after: String) {
-  categoryProducts(slug: $slug, filter: $filter, sortKey: $sortKey, reverse: $reverse, first: $first, after: $after) {
+query CategoryProducts(
+  $slug: String!,
+  $filter: String,
+  $sortKey: String,
+  $reverse: Boolean,
+  $first: Int,
+  $page: Int,
+  $after: String
+) {
+  categoryProducts(
+    slug: $slug,
+    filter: $filter,
+    sortKey: $sortKey,
+    reverse: $reverse,
+    first: $first,
+    page: $page,
+    after: $after
+  ) {
     totalCount
     description
     banner
     pageInfo {
-     endCursor     
-     hasNextPage   
+     endCursor
+     hasNextPage
     }
     products {
       cursor