zhangzf 2 недель назад
Родитель
Сommit
fde58cc929

src/app/(public)/category/[collection]/page.tsx → src/app/(public)/category copy/[collection]/page.tsx


Разница между файлами не показана из-за своего большого размера
+ 71 - 0
src/app/(public)/category/[slug]/[id]/_components/CategoryDesc.tsx


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

@@ -0,0 +1,129 @@
+"use client";
+import { useState } from "react";
+
+// 1. 定义FAQ数据类型
+export interface FaqItem {
+  id: string;
+  question: string;
+  answer?: string;
+  likeCount: number;
+}
+
+// 2. 单个FAQ折叠项子组件(内部完全管理展开逻辑,不向外暴露状态)
+function FaqAccordionItem({
+  item,
+  isOpen,
+  onToggle,
+}: {
+  item: FaqItem;
+  isOpen: boolean;
+  onToggle: () => void;
+}) {
+  // 统一上下箭头SVG,增加旋转动画
+  const ArrowIcon = (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="16"
+      height="16"
+      viewBox="0 0 16 16"
+      fill="none"
+      className="transition-transform duration-300 ease-in-out"
+      style={{ transform: isOpen ? "rotate(0deg)" : "rotate(180deg)" }}
+    >
+      <path
+        d="M2.99834 11.4952C2.74906 11.4952 2.54509 11.6991 2.54509 11.9484L2.54509 13.0604C2.54509 13.3097 2.74906 13.5137 2.99834 13.5137L13.0604 13.5137C13.3097 13.5137 13.5137 13.3097 13.5137 13.0604L13.5137 11.9484C13.5137 11.6991 13.3097 11.4952 13.0604 11.4952L2.99834 11.4952Z"
+        fill="#E5E5E5"
+      ></path>
+      <path
+        d="M13.0596 9.87793C13.3089 9.87793 13.391 9.71437 13.2421 9.51441L8.29445 2.87045C8.14556 2.67051 7.90192 2.67051 7.75305 2.87045L2.80538 9.51441C2.65648 9.71437 2.73862 9.87793 2.98791 9.87793L13.0596 9.87793Z"
+        fill="#E5E5E5"
+      ></path>
+    </svg>
+  );
+
+  // 点赞爱心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">
+      {/* 标题行:Q图标 + 问题 + 上下箭头 */}
+      <button
+        type="button"
+        onClick={onToggle}
+        className="w-full flex items-start 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">
+          Q
+        </div>
+
+        {/* 问题文字 */}
+        <h3 className="text-base font-medium text-emerald-800 flex-1">
+          {item.question}
+        </h3>
+
+        {/* 带动画切换箭头 */}
+        <div className="shrink-0 mt-1">{ArrowIcon}</div>
+      </button>
+
+      {/* 展开答案区域 */}
+      {isOpen && item.answer && (
+        <div className="mt-4">
+          {/* A标识区域 */}
+          <div className="flex gap-2 mb-3">
+            <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">
+              {item.answer}
+            </p>
+          </div>
+          {/* 点赞栏 */}
+          <div className="flex items-center justify-end gap-2 text-emerald-700 text-sm">
+            <span>{item.likeCount}</span>
+            {LikeIcon}
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 外层FAQ列表容器(所有折叠状态内部维护,不对外透出)
+export default function FaqList({ faqList }: { faqList: FaqItem[] }) {
+  // 内部维护:当前展开的FAQ ID,仅单条互斥展开
+  const [openId, setOpenId] = useState<string | null>(null);
+
+  // 只展示前3条数据
+  const displayList = faqList.slice(0, 3);
+
+  // 内部切换折叠逻辑,无需父组件参与
+  const toggleFaq = (id: string) => {
+    setOpenId(openId === id ? null : id);
+  };
+
+  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>
+    </div>
+  );
+}

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

@@ -0,0 +1,211 @@
+"use client";
+
+import { useState, useEffect, useRef } from "react";
+
+import Grid from "@components/theme/ui/grid/Grid";
+import ProductGridItems from "@components/catalog/product/ProductGridItems";
+import NewMobileFilter from "@components/theme/filters/NewMobileFilter";
+import NewSortOrder from "@components/theme/filters/NewSortOrder";
+
+import { SortByFields } from "@utils/constants";
+import { isArray } from "@/utils/type-guards";
+import { useRouter, usePathname } from "next/navigation";
+interface QueryState {
+  search: string;
+
+  sort: string;
+
+  filters: Record<string, string>;
+
+  cursor?: string;
+}
+interface ProductListingProps {
+  categoryId: string;
+
+  filterAttributes: any[];
+
+  initialProducts: any[];
+
+  initialPageInfo: any;
+
+  initialTotal: number;
+
+  initialQuery: QueryState;
+}
+
+export default function ProductListing({
+  categoryId,
+  filterAttributes,
+  initialProducts,
+  initialPageInfo,
+  initialTotal,
+  initialQuery,
+}: ProductListingProps) {
+    // 新增:页面刷新初始化源头打印
+  console.log("【页面刷新初始initialQuery】", initialQuery);
+  console.log("【刷新初始filters(传给筛选器的源头)】", initialQuery.filters);
+  const [query, setQuery] = useState(initialQuery);
+
+  const [products, setProducts] = useState(initialProducts);
+
+  const [pageInfo, setPageInfo] = useState(initialPageInfo);
+
+  const [total, setTotal] = useState(initialTotal);
+  const firstRender = useRef(true);
+  useEffect(() => {
+    if (firstRender.current) {
+      firstRender.current = false;
+      console.log("刷新页面不触发调接口");
+      
+      return;
+    }
+    fetchProducts(query);
+  }, [query.filters, query.sort, query.search]);
+  const fetchProducts = async (currentQuery: QueryState) => {
+    try {
+      const res = await fetch("/api/products", {
+        method: "POST",
+        headers: {
+          "Content-Type": "application/json",
+        },
+        body: JSON.stringify({
+          categoryId,
+
+          search: currentQuery.search,
+
+          sort: currentQuery.sort,
+
+          filters: currentQuery.filters,
+
+          first: 12,
+
+          after: null,
+        }),
+      });
+      console.log("筛选排序触发调接口");
+      const result = await res.json();
+
+      const newProducts =
+        result.data?.products?.edges?.map((e: any) => e.node) || [];
+
+      setProducts(newProducts);
+
+      setPageInfo(result.data?.products?.pageInfo);
+
+      setTotal(result.data?.products?.totalCount || 0);
+    } catch (error) {
+      console.error("fetch products error", error);
+    }
+  };
+  const router = useRouter();
+  const pathname = usePathname();
+  console.log("query-----:", query);
+  const updateQuery = (nextQuery: any) => {
+    setQuery(nextQuery);
+
+    const params = new URLSearchParams();
+
+    if (nextQuery.search) {
+      params.set("q", nextQuery.search);
+    }
+
+    if (nextQuery.sort) {
+      params.set("sort", nextQuery.sort);
+    }
+
+    Object.entries(nextQuery.filters).forEach(([key, value]) => {
+      if (value) {
+        params.set(key, value as string);
+      }
+    });
+
+    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,
+
+        search: query.search,
+
+        sort: query.sort,
+
+        filters: query.filters,
+
+        first: 15,
+
+        after: pageInfo.endCursor,
+      }),
+    });
+    console.log("触发view more调接口");
+    const result = await res.json();
+
+    const moreProducts =
+      result.data?.products?.edges?.map((e: any) => e.node) || [];
+
+    setProducts((prev) => [...prev, ...moreProducts]);
+
+    setPageInfo(result.data.products.pageInfo);
+  };
+  return (
+    <>
+      <div className="flex items-center justify-between gap-4 py-8 md:hidden w-full max-w-screen-2xl mx-auto px-4">
+        <NewMobileFilter
+          filterAttributes={filterAttributes}
+          filters={query.filters}
+          onChange={(filters) => {
+            updateQuery({
+              ...query,
+
+              filters,
+
+              cursor: undefined,
+            });
+          }}
+        />
+        <NewSortOrder
+          sortOrders={SortByFields}
+          title="Sort by"
+          value={query.sort}
+          onChange={(sort) => {
+            updateQuery({
+              ...query,
+
+              sort,
+
+              cursor: undefined,
+            });
+          }}
+        />
+      </div>
+
+      {isArray(products) && products.length > 0 ? (
+        <Grid className="grid grid-flow-row grid-cols-2 gap-5 lg:gap-11.5 w-full max-w-screen-2xl mx-auto md:grid-cols-3 lg:grid-cols-4 px-4 xss:px-7.5">
+          <ProductGridItems products={products} />
+        </Grid>
+      ) : (
+        <div className="px-4">
+          <div className="flex h-40 items-center justify-center rounded-lg border border-dashed border-neutral-300">
+            <p className="text-neutral-500">
+              No products found in this category.
+            </p>
+          </div>
+        </div>
+      )}
+      {pageInfo?.hasNextPage && (
+        <div className="flex justify-center my-10">
+          <button onClick={loadMore}>View More</button>
+        </div>
+      )}
+    </>
+  );
+}

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

@@ -0,0 +1,301 @@
+import { Metadata } from "next";
+import { notFound } from "next/navigation";
+import { isArray } from "@/utils/type-guards";
+import FilterList from "@components/theme/filters/FilterList";
+import Pagination from "@components/catalog/Pagination";
+import ProductListing from "./_components/ProductListing";
+import { ProductsResponse } from "@components/catalog/type";
+import {
+  GET_FILTER_PRODUCTS,
+  GET_TREE_CATEGORIES,
+  GET_CATEHORY_BY_ID,
+} from "@/graphql";
+import {
+  cachedGraphQLRequest,
+  cachedCategoryRequest,
+  getFilterAttributes,
+} from "@/utils/hooks/useCache";
+import { SortByFields } from "@utils/constants";
+// import { CategoryDetail } from "@components/theme/search/CategoryDetail";
+import CategoryDesc from "./_components/CategoryDesc";
+import FaqList, { FaqItem } from "./_components/FaqList";
+import { Suspense } from "react";
+import FilterListSkeleton from "@components/common/skeleton/FilterSkeleton";
+import { TreeCategoriesResponse } from "@/types/theme/category-tree";
+// import { MobileSearchBar } from "@components/layout/navbar/MobileSearch";
+import {
+  extractNumericId,
+  findCategoryBySlug,
+  buildProductFilters,
+} from "@utils/helper";
+import { serverGraphqlFetch } from "@/utils/bagisto/index";
+import {
+  CategoryNode,
+  CategorySingleResponse,
+} from "@/types/theme/category-tree";
+
+/**列表页 */
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<{ slug: string; id: string }>;
+}): Promise<Metadata> {
+  const { slug: categorySlug, id: categoryId } = await params;
+  const { data: categoryData } = await serverGraphqlFetch<
+    CategorySingleResponse,
+    { id: number }
+  >({
+    query: GET_CATEHORY_BY_ID,
+    variables: {
+      id: Number(categoryId),
+    },
+  });
+
+  //   const {data:treeData} = await cachedGraphQLRequest<TreeCategoriesResponse>(
+  //     "category",
+  //     GET_TREE_CATEGORIES,
+  //     { parentId: 1 }
+  //   );
+
+  //   const categories = treeData?.treeCategories || [];
+  //   const categoryItem = findCategoryBySlug(categories, categorySlug);
+  const categoryItem = categoryData.category;
+  if (!categoryItem) return notFound();
+
+  const translation = categoryItem.translation;
+
+  return {
+    title: translation?.metaTitle || translation?.name,
+    description: translation?.description || `${translation?.name} products`,
+  };
+}
+
+export default async function CategoryPage({
+  searchParams,
+  params,
+}: {
+  params: Promise<{ slug: string; id: string }>;
+  searchParams?: Promise<{ [key: string]: string | string[] | undefined }>;
+}) {
+  //   const { collection: categorySlug } = await params;
+  const { slug: categorySlug, id: categoryId } = await params;
+  const resolvedParams = await searchParams;
+  function getSearchParam(
+    value: string | string[] | undefined,
+  ): string | undefined {
+    return Array.isArray(value) ? value[0] : value;
+  }
+  /*
+  const [{data:treeData}, filterAttributes] = await Promise.all([
+    cachedGraphQLRequest<TreeCategoriesResponse>(
+      "category",
+      GET_TREE_CATEGORIES,
+      { parentId: 1 }
+    ),
+    getFilterAttributes(), // 这个不能用
+  ]);
+
+  const categories = treeData?.treeCategories || [];
+  const categoryItem = findCategoryBySlug(categories, categorySlug);
+  */
+  const filterAttributes = await getFilterAttributes(); // 这个不能用
+
+  console.log("filterAttributes---------------:", filterAttributes);
+  const { data: categoryData } = await serverGraphqlFetch<
+    CategorySingleResponse,
+    { id: number }
+  >({
+    query: GET_CATEHORY_BY_ID,
+    variables: {
+      id: Number(categoryId),
+    },
+  });
+  const categoryItem = categoryData.category;
+  if (!categoryItem) return notFound();
+
+  console.log("categoryItem +++++++++++", categoryItem);
+  //   const numericId = extractNumericId(categoryItem.id);
+  const numericId = String(categoryItem._id);
+
+  // const {
+  //   q: searchValue,
+  //   page,
+  //   cursor,
+  //   before,
+  // } = (resolvedParams || {}) as {
+  //   [key: string]: string;
+  // };
+  const searchValue = getSearchParam(resolvedParams?.q) ?? "";
+
+  const page = getSearchParam(resolvedParams?.page);
+
+  const cursor = getSearchParam(resolvedParams?.cursor);
+
+  const before = getSearchParam(resolvedParams?.before);
+  const itemsPerPage = 12;
+  const currentPage = page ? parseInt(page) - 1 : 0;
+  const sortValue = getSearchParam(resolvedParams?.sort) ?? "name-asc";
+  const selectedSort =
+    SortByFields.find((s) => s.key === sortValue) || SortByFields[0];
+
+  const { filterObject: baseFilterObject } = buildProductFilters(
+    resolvedParams || {},
+  );
+
+  const filterObject: Record<string, string> = {
+    ...baseFilterObject,
+  };
+
+  if (numericId) {
+    filterObject.category_id = numericId;
+  }
+
+  const filterInput = JSON.stringify(filterObject);
+
+  // 这个也改成 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,
+    filterInput,
+    itemsPerPage,
+    before,
+    cursor,
+    selectedSort,
+  );
+
+  const products = data?.products?.edges?.map((e) => e.node) || [];
+  const pageInfo = data?.products?.pageInfo;
+  const totalCount = data?.products?.totalCount || 0;
+  const translation = categoryItem.translation;
+  console.log("initialQuery------------", {
+    search: searchValue,
+    sort: sortValue,
+    filters: baseFilterObject,
+  });
+  // 模拟FAQ假数据
+  const mockFaqData: FaqItem[] = [
+    {
+      id: "1",
+      question: "How long is the delivery time for human hair wigs?",
+      answer:
+        "Standard shipping takes 7-12 working days, expedited shipping supports 3-5 days delivery worldwide.",
+      likeCount: 248,
+    },
+    {
+      id: "2",
+      question: "Is the HD lace wig invisible on all skin tones?",
+      answer:
+        "Our HD transparent lace matches all skin tones, melts perfectly without foundation.",
+      likeCount: 186,
+    },
+    {
+      id: "3",
+      question: "Can I dye and bleach the hair extensions?",
+      answer:
+        "100% human hair can be dyed darker or bleached lighter, we recommend professional hairdresser operation.",
+      likeCount: 312,
+    },
+    {
+      id: "4",
+      question: "What payment methods do you support?",
+      answer:
+        "Credit card, PayPal, Apple Pay, Google Pay and bank transfer are all available.",
+      likeCount: 97,
+    },
+    {
+      id: "5",
+      question: "How to return or exchange products?",
+      answer:
+        "Contact our customer service within 30 days after receiving the package for free exchange.",
+      likeCount: 154,
+    },
+  ];
+  return (
+    <>
+      {/* <MobileSearchBar /> */}
+      <section>
+        <Suspense fallback={<FilterListSkeleton />}>
+          {/* <CategoryDetail
+            categoryItem={{
+              description: translation?.description ?? "",
+              name: translation?.name ?? "",
+            }}
+          /> */}
+          <CategoryDesc
+            title={translation?.name ?? ""}
+            description={
+              "Alipearl Hair provides many style best quality but cheap lace wigs,including lace front wigs, lace closure wigs, full lace human hair wigs, HD transparent lace wigs, 13x4 frontal wigs, 13x6 lace front wigs, 360 lace wigs, all textures available straight, body wave, deep wave, water wave, curly hair wigs, all lengths from 8 inch to 40 inch, different hair colors natural black, brown, blonde, highlight, ginger, burgundy etc."
+            } //translation?.description ?? ""}
+          />
+        </Suspense>
+        {/* <div className="my-10 hidden gap-4 md:flex md:items-baseline md:justify-between w-full max-w-screen-2xl mx-auto px-4">
+          <FilterList filterAttributes={filterAttributes} />
+          <SortOrder sortOrders={SortByFields} title="Sort by" />
+        </div> */}
+        <ProductListing
+          categoryId={numericId}
+          filterAttributes={filterAttributes}
+          initialProducts={products}
+          initialPageInfo={pageInfo}
+          initialTotal={totalCount}
+          initialQuery={{
+            search: searchValue,
+            sort: sortValue,
+            filters: baseFilterObject,
+            cursor,
+          }}
+        />
+
+        {/* {isArray(products) &&
+          (totalCount > itemsPerPage || pageInfo?.hasNextPage) && (
+            <nav
+              aria-label="Collection pagination"
+              className="my-10 block items-center sm:flex"
+            >
+              <Pagination
+                itemsPerPage={itemsPerPage}
+                itemsTotal={totalCount || 0}
+                currentPage={currentPage}
+                nextCursor={pageInfo?.endCursor}
+                prevCursor={pageInfo?.startCursor}
+              />
+            </nav>
+          )} */}
+        <FaqList faqList={mockFaqData} />
+      </section>
+    </>
+  );
+}

+ 1 - 1
src/app/(public)/page.tsx

@@ -17,7 +17,7 @@ export default async function Home() {
 
   return (
     <>
-      <HomeImageBanner />
+      {/* <HomeImageBanner /> */}
       <DatePicker label={"Birth date"} labelPlacement={"outside"} />
       {/* <RenderThemeCustomization themeCustomizations={data?.themeCustomizations ?? {edges: []}} /> */}
       <HomeContent />

+ 24 - 2
src/app/(public)/product/_components/question/FaqList.tsx

@@ -19,6 +19,27 @@ function FaqAccordionItem({
   isOpen: boolean;
   onToggle: () => void;
 }) {
+  const ArrowIcon = (
+    <svg
+      xmlns="http://www.w3.org/2000/svg"
+      width="16"
+      height="16"
+      viewBox="0 0 16 16"
+      fill="none"
+       className={`transition-transform duration-300 ease-in-out ${
+        isOpen ? "rotate-180" : "rotate-0"
+      }`}
+    >
+      <path
+        d="M2.99834 11.4952C2.74906 11.4952 2.54509 11.6991 2.54509 11.9484L2.54509 13.0604C2.54509 13.3097 2.74906 13.5137 2.99834 13.5137L13.0604 13.5137C13.3097 13.5137 13.5137 13.3097 13.5137 13.0604L13.5137 11.9484C13.5137 11.6991 13.3097 11.4952 13.0604 11.4952L2.99834 11.4952Z"
+        fill="#E5E5E5"
+      ></path>
+      <path
+        d="M13.0596 9.87793C13.3089 9.87793 13.391 9.71437 13.2421 9.51441L8.29445 2.87045C8.14556 2.67051 7.90192 2.67051 7.75305 2.87045L2.80538 9.51441C2.65648 9.71437 2.73862 9.87793 2.98791 9.87793L13.0596 9.87793Z"
+        fill="#E5E5E5"
+      ></path>
+    </svg>
+  );
   return (
     <div className="border border-gray-100 rounded-lg bg-white p-4 mb-3">
       {/* 标题行:Q图标 + 问题 + 上下箭头 */}
@@ -39,7 +60,7 @@ function FaqAccordionItem({
 
         {/* 上下箭头 SVG 切换 */}
         <div className="shrink-0 mt-1">
-          {isOpen ? (
+          {/* {isOpen ? (
             <svg
               xmlns="http://www.w3.org/2000/svg"
               width="18"
@@ -65,7 +86,8 @@ function FaqAccordionItem({
                 d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708Z"
               />
             </svg>
-          )}
+          )} */}
+          {ArrowIcon}
         </div>
       </button>
 

+ 2 - 2
src/components/layout/navbar/MobileMenu.tsx

@@ -59,11 +59,11 @@ export default function MobileMenu({ menu, isOpen, onClose }: MobileMenuProps) {
                       className="p-2 text-xl text-black dark:text-white"
                     >
                       <Link
-                        href={item.slug ? `/category/${item.slug}` : "/search"}
+                        href={item.slug ? `/category/${item.slug}/${item._id}` : "/search"}
                         aria-label={`${item?.name}`}
                         onClick={onClose}
                       >
-                        {item.name}
+                        {item.name}{item._id} 4444
                       </Link>
                     </li>
                   ))}

+ 2 - 1
src/components/layout/navbar/index.tsx

@@ -19,7 +19,7 @@ export default async function Navbar() {
     GET_TREE_CATEGORIES,
     { parentId: 1 },
   );
-
+  console.log('TreeCategoriesResponse --- ',data)
   const categories = data?.treeCategories || [];
 
   const filteredCategories = categories
@@ -28,6 +28,7 @@ export default async function Navbar() {
       const translation = cat.translation;
       return {
         id: cat.id,
+        _id: cat._id,
         name: translation?.name || "",
         slug: translation?.slug || "",
       };

+ 205 - 0
src/components/theme/filters/NewMobileFilter.tsx

@@ -0,0 +1,205 @@
+"use client";
+
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerBody,
+} from "@heroui/drawer";
+
+import { Button } from "@heroui/button";
+
+import { useDisclosure } from "@heroui/use-disclosure";
+
+import {
+  AdjustmentsHorizontalIcon,
+  XMarkIcon,
+} from "@heroicons/react/24/outline";
+
+import { useEffect, useState } from "react";
+
+interface MobileFilterProps {
+  filterAttributes: any[];
+
+  filters: Record<string, string>;
+
+  onChange: (filters: Record<string, string>) => void;
+}
+
+export default function MobileFilter({
+  filterAttributes,
+  filters,
+  onChange,
+}: MobileFilterProps) {
+  const { isOpen, onOpen, onOpenChange } = useDisclosure();
+
+  /**
+   * 弹窗里面临时选择
+   */
+  const [tempFilters, setTempFilters] = useState<Record<string, string>>({});
+
+  const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>(
+    {},
+  );
+
+  /**
+   * 打开弹窗同步父组件状态
+   */
+  useEffect(() => {
+    if (isOpen) {
+      console.log('dadadadadadadadisopen');
+      
+      setTempFilters(filters);
+
+      const expand: any = {};
+
+      filterAttributes.forEach((attr) => {
+        expand[attr.code] = true;
+      });
+
+      setExpandedGroups(expand);
+    }
+    // 打印父组件实时传入的筛选(每次filters/isOpen变化都会打印)
+    console.log("【父组件传入筛选filters】", filters);
+    console.log("【抽屉是否打开isOpen】", isOpen);
+  }, [isOpen, filters, filterAttributes]);
+
+  /**
+   * 单选
+   */
+  const toggleOption = (code: string, id: string) => {
+    setTempFilters((prev) => {
+      const current = prev[code];
+
+      if (current === id) {
+        const next = { ...prev };
+
+        delete next[code];
+        // 打印点击后最新的临时筛选
+        console.log("【点击选项后临时筛选tempFilters】", next);
+        return next;
+      }
+
+      return {
+        ...prev,
+
+        [code]: id,
+      };
+    });
+  };
+
+  const toggleGroupExpand = (code: string) => {
+    setExpandedGroups((prev) => ({
+      ...prev,
+
+      [code]: !prev[code],
+    }));
+  };
+
+  /**
+   * 应用筛选
+   */
+  const applyFilters = () => {
+    onChange(tempFilters);
+
+    onOpenChange();
+  };
+
+  /**
+   * 清空
+   */
+  const clearAll = () => {
+    setTempFilters({});
+
+    onChange({});
+  };
+
+  const formatLabel = (str?: string) => {
+    return str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : "";
+  };
+
+  return (
+    <>
+      <div className="flex flex-wrap gap-3">
+        <Button size="md" className="flex bg-neutral-100" onPress={onOpen}>
+          <AdjustmentsHorizontalIcon className="h-6 w-8" />
+
+          <span>Filter</span>
+        </Button>
+      </div>
+
+      <Drawer
+        isOpen={isOpen}
+        placement="right"
+        onOpenChange={onOpenChange}
+        hideCloseButton
+      >
+        <DrawerContent className="h-full w-full max-w-full !rounded-none">
+          <>
+            <DrawerHeader className="flex justify-between px-6 py-4 border-b">
+              <h2 className="text-xl font-semibold">Filters:</h2>
+
+              <div className="flex gap-6">
+                <button onClick={clearAll}>Clear All</button>
+
+                <button onClick={() => onOpenChange()}>
+                  <XMarkIcon className="w-6 h-6" />
+                </button>
+              </div>
+            </DrawerHeader>
+
+            <DrawerBody className="px-6 overflow-y-auto">
+              {filterAttributes.map((attr) => {
+                const expand = expandedGroups[attr.code] ?? true;
+
+                return (
+                  <div key={attr.id} className="py-4 border-b">
+                    <button
+                      className="flex justify-between w-full text-xl"
+                      onClick={() => toggleGroupExpand(attr.code)}
+                    >
+                      <span>{formatLabel(attr.adminName)}</span>
+
+                      <span>⌄</span>
+                    </button>
+
+                    {expand && (
+                      <div className="flex flex-col gap-3 mt-4">
+                        {(attr.options || []).map((opt: any) => {
+                          const checked = tempFilters[attr.code] === opt.id;
+
+                          return (
+                            <label key={opt.id} className="flex gap-3 text-lg">
+                              <input
+                                type="checkbox"
+                                checked={checked}
+                                onChange={() => toggleOption(attr.code, opt.id)}
+                              />
+
+                              <span>{opt.adminName}</span>
+                            </label>
+                          );
+                        })}
+                      </div>
+                    )}
+                  </div>
+                );
+              })}
+            </DrawerBody>
+
+            <div className="px-6 py-4 border-t">
+              <Button
+                color="primary"
+                size="lg"
+                className="w-full"
+                onPress={applyFilters}
+              >
+                Apply Filter
+              </Button>
+            </div>
+          </>
+        </DrawerContent>
+      </Drawer>
+    </>
+  );
+}

+ 97 - 0
src/components/theme/filters/NewSortOrder.tsx

@@ -0,0 +1,97 @@
+"use client";
+
+import { FC } from "react";
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerBody,
+} from "@heroui/drawer";
+import { Button } from "@heroui/button";
+import { useDisclosure } from "@heroui/use-disclosure";
+
+import { SortOrderTypes } from "@/utils/constants";
+import { SortIcon } from "@components/common/icons/SortIcon";
+
+interface NewSortOrderProps {
+  sortOrders: SortOrderTypes[];
+  title: string;
+  value: string;
+  onChange: (sort: string) => void;
+}
+
+const NewSortOrder: FC<NewSortOrderProps> = ({
+  sortOrders,
+  title,
+  value,
+  onChange,
+}) => {
+  const { isOpen, onOpen, onOpenChange } = useDisclosure();
+
+  const handleSortChange = (sort: string) => {
+    onChange(sort);
+    onOpenChange();
+  };
+
+  return (
+    <>
+      <div className="flex flex-wrap gap-3">
+        <Button
+          size="md"
+          variant="flat"
+          className="bg-neutral-100 dark:bg-neutral-800"
+          onPress={onOpen}
+        >
+          <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>
+
+          <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">
+            {/* 列表容器,完全复刻截图样式 */}
+            <div className="flex flex-col">
+              {sortOrders.map((order) => {
+                const isActive = value === order.value;
+                return (
+                  <button
+                    key={order.value}
+                    onClick={() => handleSortChange(order.value)}
+                    className={`w-full text-center py-4 text-xl transition-colors ${
+                      isActive
+                        ? "bg-neutral-100 dark:bg-neutral-800 font-semibold"
+                        : "bg-transparent"
+                    }`}
+                  >
+                    {order.title}
+                  </button>
+                );
+              })}
+            </div>
+          </DrawerBody>
+        </DrawerContent>
+      </Drawer>
+    </>
+  );
+};
+
+export default NewSortOrder;

+ 23 - 0
src/graphql/catalog/queries/GetCategoryById.ts

@@ -0,0 +1,23 @@
+
+import { gql,TypedDocumentNode } from "@apollo/client";
+import {CategorySingleResponse} from "@/types/theme/category-tree";
+
+
+export const GET_CATEHORY_BY_ID: TypedDocumentNode<CategorySingleResponse> = gql`
+query getCategoryByID($id: ID!) {
+  category(id: $id) {
+    id
+    _id
+    position
+    status
+    logoPath
+    translation {
+      name
+      slug
+      urlPath
+      description
+
+      metaTitle
+    }
+  }
+}`;

+ 1 - 0
src/graphql/catalog/queries/GetTreeCategories.ts

@@ -8,6 +8,7 @@ export const GET_TREE_CATEGORIES = gql`
   query treeCategories($parentId: Int) {
     treeCategories(parentId: $parentId) {
       id
+      _id
       position
       logoPath
       status

+ 1 - 0
src/graphql/catalog/queries/index.ts

@@ -9,3 +9,4 @@ export { GET_FILTER_ATTRIBUTES } from "./GetFilterAttributes";
 export { GET_FILTER_PRODUCTS } from "./GetFilterProducts";
 export { GET_TREE_CATEGORIES } from "./GetTreeCategories";
 export { SEARCH_PRODUCTS } from "./SearchProducts";
+export { GET_CATEHORY_BY_ID } from "./GetCategoryById";

+ 5 - 0
src/types/theme/category-tree.ts

@@ -4,6 +4,7 @@ export interface TreeCategoriesVariables {
 
 export interface CategoryNode {
   id: string;
+  _id: number;
   position: number;
   logoPath?: string | null;
   status: string;
@@ -30,3 +31,7 @@ export interface CategoryTranslationEdge {
 export interface CategoryTranslationConnection {
   edges: CategoryTranslationEdge[];
 }
+
+export interface CategorySingleResponse {
+  category: CategoryNode
+}

+ 1 - 1
src/utils/hooks/useSearchProduct.ts

@@ -23,7 +23,7 @@ export function useSearchProduct() {
     }
 
     try {
-      const searchRes = await apolloClient.query<SearchProductsResponse, SearchProductsVariables>({
+      const searchRes = await apolloClient.query({
         query: SEARCH_PRODUCTS,
         variables,
         fetchPolicy: "no-cache",