Prechádzať zdrojové kódy

搜索页和产品详情页调整

zhangzf 1 týždeň pred
rodič
commit
d217858ea9

BIN
public/image/products_details/afterpay.png


BIN
public/image/products_details/klarna.png


BIN
public/image/products_details/paypal-expressm.png


+ 66 - 58
src/app/(public)/product/[...urlProduct]/page.tsx

@@ -1,15 +1,13 @@
 import { notFound } from "next/navigation";
 import { Suspense } from "react";
 // import clsx from "clsx";
-
+import type { ProductReviewList } from "@/types/products/productDetail";
+import { getProductReviews } from "@utils/hooks/getProductReviews";
 import {
   ProductDetailSkeleton,
   RelatedProductSkeleton,
 } from "@/components/common/skeleton/ProductSkeleton";
-import {
-  BASE_SCHEMA_URL,
-  PRODUCT_TYPE,
-} from "@/utils/constants";
+import { BASE_SCHEMA_URL, PRODUCT_TYPE } from "@/utils/constants";
 import { GET_PRODUCT_BY_URL_KEY } from "@/graphql";
 import { cachedProductRequest } from "@/utils/hooks/useCache";
 import {
@@ -19,26 +17,24 @@ import {
 } from "@components/catalog/type";
 import { RelatedProductsSection } from "@components/catalog/product/RelatedProductsSection";
 import { HeroCarouselShimmer } from "@components/common/slider";
-import {ProductMedia} from "@/app/(public)/product/_components/ProductMedia";
+import { ProductMedia } from "@/app/(public)/product/_components/ProductMedia";
 import { ProductInformation } from "@/app/(public)/product/_components/ProductInformation";
 import { ProductShortDescription } from "@/app/(public)/product/_components/ProductShortDescription";
 import { ProductDetail } from "@/app/(public)/product/_components/ProductDetail";
-import { ProductReviewSection } from "@/app/(public)/product/_components/ProductReviewSection";
-import {
-    formatFlexibleVariants
-} from "@/utils/variantTools";
+import SwitchButton from "../_components/SwitchButton";
+import { formatFlexibleVariants } from "@/utils/variantTools";
 
 // export const dynamic = 'auto'
 // // 'auto' | 'force-dynamic' | 'error' | 'force-static'
-export const dynamic = 'force-dynamic';
+export const dynamic = "force-dynamic";
 async function getSingleProduct(urlKey: string) {
-
   try {
-    const {data:dataById} = await cachedProductRequest<SingleProductResponse>(
-      // urlKey, // 产品名称
-      GET_PRODUCT_BY_URL_KEY, // gql查询语句
-      { urlKey: urlKey },
-    );
+    const { data: dataById } =
+      await cachedProductRequest<SingleProductResponse>(
+        // urlKey, // 产品名称
+        GET_PRODUCT_BY_URL_KEY, // gql查询语句
+        { urlKey: urlKey },
+      );
 
     const product = dataById?.product || null;
 
@@ -66,7 +62,10 @@ export default async function ProductPage({
   const fullPath = urlProduct.join("/");
   const product = await getSingleProduct(fullPath);
   if (!product) return notFound();
-
+  const allReviews: ProductReviewList = await getProductReviews(
+    String(product._id),
+  );
+  const questionTotal = 73;
   // const imageUrl = getImageUrl(product?.baseImageUrl, baseUrl, NOT_IMAGE);
 
   // JSON-LD数据格式,便于搜索引擎识别页面信息,利于seo
@@ -78,16 +77,20 @@ export default async function ProductPage({
     sku: product?.sku,
   };
 
-  const mediaImgs = (product?.images?.edges ?? []).map((edge: { node: ProductMediaType }) => {
-    return edge.node;
-  });
-  const productOptions: ProductOption[] = product?.productOptions ? JSON.parse(product?.productOptions) : [];
+  const mediaImgs = (product?.images?.edges ?? []).map(
+    (edge: { node: ProductMediaType }) => {
+      return edge.node;
+    },
+  );
+  const productOptions: ProductOption[] = product?.productOptions
+    ? JSON.parse(product?.productOptions)
+    : [];
 
   const flexibleVariants = formatFlexibleVariants(product?.flexibleVariants);
 
-    // const reviews = Array.isArray(product?.reviews?.edges)
-    // ? product?.reviews.edges.map((e) => e.node)
-    // : [];
+  // const reviews = Array.isArray(product?.reviews?.edges)
+  // ? product?.reviews.edges.map((e) => e.node)
+  // : [];
 
   // const VariantImages = isArray(product?.variants?.edges)
   //   ? product?.variants.edges.map(
@@ -97,40 +100,45 @@ export default async function ProductPage({
 
   return (
     <>
-        <script //SEO
-            dangerouslySetInnerHTML={{
-            __html: JSON.stringify(productJsonLd),
-            }}
-            type="application/ld+json"
-        />
-        <div className="w-full">
-            <Suspense fallback={<HeroCarouselShimmer />}>
-                <ProductMedia mediaData={mediaImgs} />
-            </Suspense>
-        </div>
-
-
-        
-
-        <div className="">
-            <Suspense fallback={<ProductDetailSkeleton />}>
-                <ProductInformation key={product._id}
-                    productId={product._id}
-                    name={product?.name || ''}
-                    productOptions={productOptions}
-                    flexibleVariants={flexibleVariants}
-                    isSaleable={product.isSaleable}
-                />
-                <ProductShortDescription shortDescription={product.shortDescription || ''} />
-                <ProductDetail detail={product.description || ''} />
-
-                <ProductReviewSection productId={String(product._id)} />
-            </Suspense>
-        </div>
-
-        <Suspense fallback={<RelatedProductSkeleton />}>
-            <RelatedProductsSection fullPath={fullPath} />
+      <script //SEO
+        dangerouslySetInnerHTML={{
+          __html: JSON.stringify(productJsonLd),
+        }}
+        type="application/ld+json"
+      />
+      <div className="w-full">
+        <Suspense fallback={<HeroCarouselShimmer />}>
+          <ProductMedia mediaData={mediaImgs} />
+        </Suspense>
+      </div>
+
+      <div className="">
+        <Suspense fallback={<ProductDetailSkeleton />}>
+          <ProductInformation
+            key={product._id}
+            productId={product._id}
+            name={product?.name || ""}
+            productOptions={productOptions}
+            flexibleVariants={flexibleVariants}
+            isSaleable={product.isSaleable}
+          />
+          <ProductShortDescription
+            shortDescription={product.shortDescription || ""}
+          />
+          <ProductDetail detail={product.description || ""} />
+          <SwitchButton
+            productId={String(product._id)}
+            reviewList={allReviews}
+            reviewTotal={allReviews.length}
+            questionTotal={questionTotal}
+          />
+          {/* <ProductReviewSection reviews={allReviews} productId={String(product._id)} /> */}
         </Suspense>
+      </div>
+
+      <Suspense fallback={<RelatedProductSkeleton />}>
+        <RelatedProductsSection fullPath={fullPath} />
+      </Suspense>
     </>
   );
 }

+ 63 - 0
src/app/(public)/product/_components/NewUserBanner.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+export default function NewUserBanner() {
+  return (
+    <div className="w-full flex items-center justify-between mt-1 bg-[#F9F9F9] px-3 py-2 ">
+      {/* 左侧:用户头像SVG + 文案 */}
+      <div className="flex items-center gap-2">
+        {/* 用户头像绿色SVG */}
+
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          width="16"
+          height="16"
+          viewBox="0 0 16 16"
+          fill="none"
+        >
+          <rect
+            x="0"
+            y="0"
+            width="16"
+            height="16"
+            fill="#CCCCCC"
+            fill-opacity="0"
+          ></rect>
+          <ellipse
+            cx="7.998697280883789"
+            cy="4.666656494140625"
+            rx="2.666666030883789"
+            ry="2.666656494140625"
+            fill="#036141"
+          ></ellipse>
+          <path
+            d="M13.3346 14.0003C13.3346 11.0548 10.9468 8.66699 8.0013 8.66699C5.05578 8.66699 2.66797 11.0548 2.66797 14.0003L13.3346 14.0003Z"
+            fill="#036141"
+          ></path>
+        </svg>
+        {/* 绿色文案 */}
+        <span className="text-[#036141] text-ly-14 leading-ly-22 font-medium">
+          New User Deal · Sign In & Save $200
+        </span>
+      </div>
+
+      {/* 右侧右箭头SVG按钮 */}
+      <button className="shrink-0 hover:opacity-70 transition-opacity">
+        <svg
+          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)"
+            stroke-width="1.5"
+            stroke-linejoin="round"
+            stroke-linecap="square"
+            d="M9 18L15 12L9 6"
+          ></path>
+        </svg>
+      </button>
+    </div>
+  );
+}

+ 378 - 255
src/app/(public)/product/_components/ProductInformation.tsx

@@ -2,296 +2,419 @@
 
 import { useState, useMemo } from "react";
 import clsx from "clsx";
-import { 
-    ProductOption, 
-    ResolvedVariant
-} from "@/components/catalog/type";
+import { ProductOption, ResolvedVariant } from "@/components/catalog/type";
 import { ProductAddToCart } from "@/app/(public)/product/_components/ProductAddToCart";
 import { useCustomToast } from "@utils/hooks/useToast";
 import { useAddProduct } from "@utils/hooks/useAddToCart";
-import { redirect, RedirectType } from 'next/navigation'
+import { redirect, RedirectType } from "next/navigation";
+import InstallmentPopup from "./popup/InstallmentPopup";
+import ProductText from "./ProductText";
+import NewUserBanner from "./NewUserBanner";
+import PromotionDiscountCardProps from "./PromotionDiscountCardProps ";
+import Image from "next/image";
 import {
-    isValueAvailable,
-    isOptionValueAvailable,
-    getFirstAvailable,
-    getAvailableVariants,
-    isCombinationAvailable,
-    findNearestAvailableVariant
+  isValueAvailable,
+  isOptionValueAvailable,
+  getFirstAvailable,
+  getAvailableVariants,
+  isCombinationAvailable,
+  findNearestAvailableVariant,
 } from "@/utils/variantTools";
 import { Price } from "@components/theme/ui/Price";
 
-
 export function ProductInformation({
-    name,
-    productId,
-    productOptions,
-    flexibleVariants,
-    isSaleable
+  name,
+  productId,
+  productOptions,
+  flexibleVariants,
+  isSaleable,
 }: {
-    name: string;
-    productId: number;
-    productOptions: ProductOption[];
-    flexibleVariants: ResolvedVariant[];
-    isSaleable: string | undefined;
+  name: string;
+  productId: number;
+  productOptions: ProductOption[];
+  flexibleVariants: ResolvedVariant[];
+  isSaleable: string | undefined;
 }) {
-    const { isCartLoading, onAddToCart } = useAddProduct();
-    const { showToast } = useCustomToast();
-    
-    // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
-    const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0]?.id );
+  const { isCartLoading, onAddToCart } = useAddProduct();
+  const { showToast } = useCustomToast();
+
+  // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
+  const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(
+    productOptions[0]?.id,
+  );
 
-    const [productQty, setProductQty] = useState(1);
+  const [productQty, setProductQty] = useState(1);
+  const [modalOpen, setModalOpen] = useState(false);
+  // 获取所有可用变体(quantity > 0)
+  const availableVariants = useMemo(() => {
+    return getAvailableVariants(flexibleVariants);
+  }, [flexibleVariants]);
 
-    // 获取所有可用变体(quantity > 0)
-    const availableVariants = useMemo(() => {
-        return getAvailableVariants(flexibleVariants);
-    }, [flexibleVariants]);
+  // 当前选中的选项值映射:option_id -> value_id
+  const [selected, setSelected] = useState<Record<number, number>>(() => {
+    let res: Record<number, number> = {};
 
-    // 当前选中的选项值映射:option_id -> value_id
-    const [selected, setSelected] = useState<Record<number, number>>(() => {
-        let res: Record<number, number> = {};
+    const defaultSelected = getFirstAvailable(productOptions, flexibleVariants);
+    if (defaultSelected) {
+      res = defaultSelected;
+    } else {
+      // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
+      productOptions.forEach((opt) => {
+        res[opt.id] = opt.values[0]?.id;
+      });
+    }
+    console.log("set default selected");
+    return res;
+  });
 
-        const defaultSelected = getFirstAvailable(productOptions, flexibleVariants);;
-        if (defaultSelected) {
-            res = defaultSelected;
+  // 计算每个选项值的禁用状态
+  const disabledMap = useMemo(() => {
+    const map: Record<number, boolean> = {};
+    if (Object.keys(selected).length === 0) return map; // 没有选中的项
+
+    productOptions.forEach((option) => {
+      option.values.forEach((value) => {
+        // 规则:如果该选项组是最近点击的组,校验组中的值是否存在可用变体
+        if (option.id === lastClickedOptionId) {
+          //   map[value.id] = false;
+          map[value.id] = !isValueAvailable(value.id, availableVariants);
         } else {
-            // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
-            productOptions.forEach((opt) => {
-                res[opt.id] = opt.values[0]?.id;
-            });
+          // 校验其他组的选项值是否可用
+          map[value.id] = !isOptionValueAvailable(
+            option.id,
+            value.id,
+            selected,
+            productOptions,
+            availableVariants,
+          );
         }
-        console.log('set default selected');
-        return res;
+      });
     });
+    return map;
+  }, [selected, lastClickedOptionId, productOptions, availableVariants]);
 
-    // 计算每个选项值的禁用状态
-    const disabledMap = useMemo(() => {
-        const map: Record<number, boolean> = {};
-        if (Object.keys(selected).length === 0) return map; // 没有选中的项
-
-        productOptions.forEach((option) => {
-            option.values.forEach((value) => {
-                // 规则:如果该选项组是最近点击的组,校验组中的值是否存在可用变体
-                if (option.id === lastClickedOptionId) {
-                    //   map[value.id] = false;
-                    map[value.id] = !isValueAvailable(value.id,availableVariants);
-                } else {
-                    // 校验其他组的选项值是否可用
-                    map[value.id] = !isOptionValueAvailable(
-                        option.id,
-                        value.id,
-                        selected,
-                        productOptions,
-                        availableVariants
-                    );
-                }
-            });
-        });
-        return map;
-    }, [selected, lastClickedOptionId, productOptions, availableVariants]);
-
-    // 处理选项点击
-    const handleOptionClick = (optionId: number, valueId: number) => {
-        // 更新前需要判断当前选中的组合是否可以购买,如果可以购买正常更新;如果不能购买查找可以购买的组合然后更新
-        let newSelected: Record<number, number> = {...selected,[optionId]: valueId};
-        const selectedValueIds = Object.values(newSelected);
-        if(!isCombinationAvailable(selectedValueIds, availableVariants)) {
-            newSelected = findNearestAvailableVariant(optionId,valueId,{...newSelected},productOptions,availableVariants);
-        }
-        setSelected({ ...newSelected });
-        // 记录当前点击的选项组
-        setLastClickedOptionId(optionId);
+  // 处理选项点击
+  const handleOptionClick = (optionId: number, valueId: number) => {
+    // 更新前需要判断当前选中的组合是否可以购买,如果可以购买正常更新;如果不能购买查找可以购买的组合然后更新
+    let newSelected: Record<number, number> = {
+      ...selected,
+      [optionId]: valueId,
     };
+    const selectedValueIds = Object.values(newSelected);
+    if (!isCombinationAvailable(selectedValueIds, availableVariants)) {
+      newSelected = findNearestAvailableVariant(
+        optionId,
+        valueId,
+        { ...newSelected },
+        productOptions,
+        availableVariants,
+      );
+    }
+    setSelected({ ...newSelected });
+    // 记录当前点击的选项组
+    setLastClickedOptionId(optionId);
+  };
 
-    // 判断当前选中的组合是否可购买(用于控制购买按钮等)
-    const isCurrentSelectionAvailable = useMemo(() => {
-        let res = true;
-        if(isSaleable !== '1') {
-            res = false;
-        } else {
-            const selectedIds = Object.values(selected);
-            if (selectedIds.length !== productOptions.length) {
-                res = false
-            } else {
-                res = isCombinationAvailable(selectedIds, availableVariants);
-            }
-        }
-        return res;
-    }, [selected, productOptions, flexibleVariants, isSaleable]);
-
-    // 获取当前选中变体和价格等信息
-    // useEffect和useMemo的执行时机不同,useMemo早于useEffect执行,如果使用useEffect初始化selected,则需要对selected做判断,当为空对象时返回占位对象
-    const currentVariantInfo: {variant:ResolvedVariant | null; totalLinePrice: number; totalNowPrice: number; save: number;} = useMemo(() => {
-        const selectedIds = Object.values(selected);
-        let totalLinePrice = 0;
-        let totalNowPrice = 0;
-        let save = 0;
-        const variant = flexibleVariants.find((v) => {
-            const vIds = v.optionValues.map((ov) => ov.id);
-            return selectedIds.length === vIds.length && selectedIds.every((id) => vIds.includes(id));
-        });
-        if (selectedIds.length === 0 || variant === undefined) {
-            // 尚未初始化,返回一个安全占位对象
-            return { variant: null, totalLinePrice: 0, totalNowPrice: 0, save: 0 };
-        }
-        
-        if(variant.priceIndices && variant.priceIndices.length > 0) {
-            totalLinePrice = Number(variant.priceIndices[0].regular_min_price) * productQty;
-            totalNowPrice = Number(variant.priceIndices[0].min_price) * productQty;
-        }
-        if(totalLinePrice !== totalNowPrice) {
-            save = -(totalLinePrice - totalNowPrice); 
-        }
+  // 判断当前选中的组合是否可购买(用于控制购买按钮等)
+  const isCurrentSelectionAvailable = useMemo(() => {
+    let res = true;
+    if (isSaleable !== "1") {
+      res = false;
+    } else {
+      const selectedIds = Object.values(selected);
+      if (selectedIds.length !== productOptions.length) {
+        res = false;
+      } else {
+        res = isCombinationAvailable(selectedIds, availableVariants);
+      }
+    }
+    return res;
+  }, [selected, productOptions, flexibleVariants, isSaleable]);
 
-        return {
-            variant, 
-            totalLinePrice: totalLinePrice, 
-            totalNowPrice: totalNowPrice,
-            save: save
-        };
-    }, [selected, flexibleVariants, productQty]);
+  // 获取当前选中变体和价格等信息
+  // useEffect和useMemo的执行时机不同,useMemo早于useEffect执行,如果使用useEffect初始化selected,则需要对selected做判断,当为空对象时返回占位对象
+  const currentVariantInfo: {
+    variant: ResolvedVariant | null;
+    totalLinePrice: number;
+    totalNowPrice: number;
+    save: number;
+    displayDayPrice: number;
+  } = useMemo(() => {
+    const selectedIds = Object.values(selected);
+    let totalLinePrice = 0;
+    let totalNowPrice = 0;
+    let save = 0;
+    let displayDayPrice = 0;
+    const variant = flexibleVariants.find((v) => {
+      const vIds = v.optionValues.map((ov) => ov.id);
+      return (
+        selectedIds.length === vIds.length &&
+        selectedIds.every((id) => vIds.includes(id))
+      );
+    });
+    if (selectedIds.length === 0 || variant === undefined) {
+      // 尚未初始化,返回一个安全占位对象
+      return {
+        variant: null,
+        totalLinePrice: 0,
+        totalNowPrice: 0,
+        save: 0,
+        displayDayPrice: 0,
+      };
+    }
 
+    if (variant.priceIndices && variant.priceIndices.length > 0) {
+      totalLinePrice =
+        Number(variant.priceIndices[0].regular_min_price) * productQty;
+      totalNowPrice = Number(variant.priceIndices[0].min_price) * productQty;
+      const dayPrice = totalNowPrice / 30; // 30天一期,日均成本
+      displayDayPrice = Number(dayPrice.toFixed(2));
+    }
+    if (totalLinePrice !== totalNowPrice) {
+      save = -(totalLinePrice - totalNowPrice);
+    }
 
-    const classNameOptionValueBtn = "text-ly-14 leading-ly-24 border border-solid rounded-lg pt-1.5 pb-1.5 pl-3 pr-3 bg-white";
+    return {
+      variant,
+      totalLinePrice: totalLinePrice,
+      totalNowPrice: totalNowPrice,
+      save: save,
+      displayDayPrice: displayDayPrice,
+    };
+  }, [selected, flexibleVariants, productQty]);
 
+  const classNameOptionValueBtn =
+    "text-ly-14 leading-ly-24 border border-solid rounded-lg pt-1.5 pb-1.5 pl-3 pr-3 bg-white";
 
-    const handlerProductQty = (action: string) => {
-        if (action === "increase") {
-            setProductQty(productQty + 1);
-        } else if (action === "decrease" && productQty > 1) {
-            setProductQty(productQty - 1);
-        }
+  const handlerProductQty = (action: string) => {
+    if (action === "increase") {
+      setProductQty(productQty + 1);
+    } else if (action === "decrease" && productQty > 1) {
+      setProductQty(productQty - 1);
     }
+  };
 
-    async function addProductToCart(action:string = 'addtocart') {
-        
-        const params = { 
-            productId: String(productId), 
-            quantity: productQty,
-            variantId: currentVariantInfo.variant?._id,
-        };
-        const res = await onAddToCart(params);
-        console.log('onAddToCart result --- ', res);
-        if(action === 'buynow') {
-            if(res) {
-                const responseData = res.data?.createAddProductInCart?.addProductInCart;
-                if(responseData && responseData.success) {
-                    redirect('/checkout?step=address', RedirectType.push);
-                }
-            }
+  async function addProductToCart(action: string = "addtocart") {
+    const params = {
+      productId: String(productId),
+      quantity: productQty,
+      variantId: currentVariantInfo.variant?._id,
+    };
+    const res = await onAddToCart(params);
+    console.log("onAddToCart result --- ", res);
+    if (action === "buynow") {
+      if (res) {
+        const responseData = res.data?.createAddProductInCart?.addProductInCart;
+        if (responseData && responseData.success) {
+          redirect("/checkout?step=address", RedirectType.push);
         }
-    } 
+      }
+    }
+  }
 
-    const addToCartHandler = async () => {
-   
-        if(!isCurrentSelectionAvailable) {
-            showToast("The selected options are not available!", "warning");
-            return;
-        }
+  const addToCartHandler = async () => {
+    if (!isCurrentSelectionAvailable) {
+      showToast("The selected options are not available!", "warning");
+      return;
+    }
 
-        if(currentVariantInfo.variant && !isCartLoading) {
-            addProductToCart();
-        }
-    };
+    if (currentVariantInfo.variant && !isCartLoading) {
+      addProductToCart();
+    }
+  };
 
-    const buyNowHandler = () => {
-        if(!isCurrentSelectionAvailable) {
-            showToast("The selected options are not available!", "warning");
-            return;
-        }
-        if(currentVariantInfo.variant && !isCartLoading) {
-            addProductToCart('buynow');
-        }
-    };
+  const buyNowHandler = () => {
+    if (!isCurrentSelectionAvailable) {
+      showToast("The selected options are not available!", "warning");
+      return;
+    }
+    if (currentVariantInfo.variant && !isCartLoading) {
+      addProductToCart("buynow");
+    }
+  };
+  const currencySymbol = "$";
 
+  return (
+    <>
+      <div className="box-border w-full pr-4 pl-4">
+        <h3 className="text-ly-14 text-black mt-4 leading-ly-22">
+          {name} {isSaleable}
+        </h3>
+        <ProductText ProductText="" />
+        <div className="flex items-center mt-2">
+          <Price
+            className="text-ly-16 leading-ly-24 font-bold"
+            amount={String(currentVariantInfo.totalNowPrice)}
+            currencyCode="USD"
+          />
+          {currentVariantInfo.totalLinePrice !==
+            currentVariantInfo.totalNowPrice && (
+            <>
+              <Price
+                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
+                amount={String(currentVariantInfo.totalLinePrice)}
+                currencyCode="USD"
+              />
+              <Price
+                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
+                amount={String(currentVariantInfo.totalLinePrice)}
+                currencyCode="USD"
+              />
+              <Price
+                className="text-ly-12 leading-ly-16 bg-ly-gold pr-1.5 pl-1.5 ml-3"
+                amount={String(currentVariantInfo.save)}
+                currencyCode="USD"
+              />
+            </>
+          )}
+        </div>
 
-    return (<>
-        <div className="box-border w-full pr-4 pl-4">
-            <h3 className="text-ly-12 mt-4 leading-ly-22">{name} {isSaleable}</h3>
-      
-            <div className="flex items-center mt-2">
-                <Price 
-                    className="text-ly-16 leading-ly-24 font-bold"
-                    amount={String(currentVariantInfo.totalNowPrice)} 
-                    currencyCode="USD"
-                />
-                {
-                    currentVariantInfo.totalLinePrice !== currentVariantInfo.totalNowPrice && (
-                        <>
-                            <Price 
-                                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
-                                amount={String(currentVariantInfo.totalLinePrice)} 
-                                currencyCode="USD"
-                            /><Price 
-                                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
-                                amount={String(currentVariantInfo.totalLinePrice)} 
-                                currencyCode="USD"
-                            />
-                            <Price 
-                                className="text-ly-12 leading-ly-16 bg-ly-gold pr-1.5 pl-1.5 ml-3"
-                                amount={String(currentVariantInfo.save)} 
-                                currencyCode="USD"
-                            />
-                        </>
-                    )
-                }
-                
-            </div>
-            
+        <div className="w-full flex items-center gap-2 py-2">
+          <span className="text-black text-ly-12 leading-ly-20 font-normal ">
+            As Low as{" "}
+            <b>
+              {currencySymbol}
+              {currentVariantInfo.displayDayPrice}
+            </b>
+            /day with
+          </span>
+          <div className="">
+            <Image
+              src={"/image/products_details/klarna.png"}
+              width={44}
+              height={18}
+              alt="klarna"
+            />
+          </div>
+          <div className="">
+            <Image
+              src={"/image/products_details/afterpay.png"}
+              width={56}
+              height={18}
+              alt="afterpay"
+            />
+          </div>
+          <div className="">
+            <Image
+              src={"/image/products_details/paypal-expressm.png"}
+              width={56}
+              height={18}
+              alt="paypal-expressm"
+            />
+          </div>
+          <button
+            onClick={() => setModalOpen(true)}
+            className="w-4 h-4 rounded-full border border-black flex items-center justify-center text-black font-medium text-ly-12  "
+          >
+            ?
+          </button>
+          <InstallmentPopup
+            open={modalOpen}
+            onClose={() => setModalOpen(false)}
+          />
         </div>
+        <NewUserBanner />
+        {/* 产品Code */}
+        <PromotionDiscountCardProps productId="product-1001" />
+      </div>
 
-        <div className="box-border w-full pr-4 pl-4 mt-6">
-            {productOptions.map((option) => (
-            <div key={option.id}>
-                <div className="text-ly-12 font-medium">{option.label}</div>
-                <div className="flex flex-wrap gap-3 mt-3">
-                {option.values.map((value) => {
-                    const isSelected = selected[option.id] === value.id;
-                    const isDisabled = disabledMap[value.id] || false;
-                    return (
-                    <button
-                        key={value.id}
-                        type="button"
-                        onClick={() => !isDisabled && handleOptionClick(option.id, value.id)}
-                        className={clsx(classNameOptionValueBtn, {
-                            'border-ly-green text-ly-green': isSelected,
-                            'border-black text-black': !isSelected,
-                            'opacity-40 cursor-not-allowed strokeline-bg-disabled': isDisabled
-                        })}
-                        
-                        disabled={isDisabled}
-                    >
-                        {value.label}
-                    </button>
-                    );
-                })}
-                </div>
+      <div className="box-border w-full pr-4 pl-4 mt-4">
+        {productOptions.map((option) => (
+          <div key={option.id}>
+            <div className="text-ly-12 font-medium">{option.label}</div>
+            <div className="flex flex-wrap gap-3 mt-3">
+              {option.values.map((value) => {
+                const isSelected = selected[option.id] === value.id;
+                const isDisabled = disabledMap[value.id] || false;
+                return (
+                  <button
+                    key={value.id}
+                    type="button"
+                    onClick={() =>
+                      !isDisabled && handleOptionClick(option.id, value.id)
+                    }
+                    className={clsx(classNameOptionValueBtn, {
+                      "border-ly-green text-ly-green": isSelected,
+                      "border-black text-black": !isSelected,
+                      "opacity-40 cursor-not-allowed strokeline-bg-disabled":
+                        isDisabled,
+                    })}
+                    disabled={isDisabled}
+                  >
+                    {value.label}
+                  </button>
+                );
+              })}
             </div>
-            ))}
+          </div>
+        ))}
 
-            <div className="text-ly-12 font-medium mt-4">Quantity*</div>
-            <div className="w-full flex mt-3">
-                <div className="flex border border-solid rounded-lg">
-                    <button onClick={() => handlerProductQty('decrease')} type="button" className="flex-none w-7.5 h-9 flex items-center justify-center">
-                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
-                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8" />
-                        </svg>
-                    </button>
-                    <input type="text" className="w-15 h-9 leading-ly-36 text-center" value={productQty} readOnly />
-                    <button onClick={() => handlerProductQty('increase')} type="button" className="flex-none w-7.5 h-9 flex items-center justify-center">
-                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
-                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M8.02026 3.3335L8.00806 12.6668" />
-                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8" />
-                        </svg>
-                    </button>
-                </div>
-            </div>
+        <div className="w-full flex justify-between items-center mt-3">
+          <div className="text-ly-14 text-black  font-medium ">Quantity*</div>
+          <div className="flex border border-solid rounded-lg">
+            <button
+              onClick={() => handlerProductQty("decrease")}
+              type="button"
+              className="flex-none w-7.5 h-11 flex items-center justify-center"
+            >
+              <svg
+                className="w-4 h-4"
+                xmlns="http://www.w3.org/2000/svg"
+                width="16"
+                height="16"
+                viewBox="0 0 16 16"
+                fill="none"
+              >
+                <path
+                  stroke="rgba(0, 0, 0, 1)"
+                  strokeWidth="1.5"
+                  strokeLinejoin="round"
+                  d="M3.33325 8L12.6666 8"
+                />
+              </svg>
+            </button>
+            <input
+              type="text"
+              className="w-15 h-11 leading-ly-36 text-center"
+              value={productQty}
+              readOnly
+            />
+            <button
+              onClick={() => handlerProductQty("increase")}
+              type="button"
+              className="flex-none w-7.5 h-11 flex items-center justify-center"
+            >
+              <svg
+                className="w-4 h-4"
+                xmlns="http://www.w3.org/2000/svg"
+                width="16"
+                height="16"
+                viewBox="0 0 16 16"
+                fill="none"
+              >
+                <path
+                  stroke="rgba(0, 0, 0, 1)"
+                  strokeWidth="1.5"
+                  strokeLinejoin="round"
+                  d="M8.02026 3.3335L8.00806 12.6668"
+                />
+                <path
+                  stroke="rgba(0, 0, 0, 1)"
+                  strokeWidth="1.5"
+                  strokeLinejoin="round"
+                  d="M3.33325 8L12.6666 8"
+                />
+              </svg>
+            </button>
+          </div>
         </div>
-        <ProductAddToCart 
-            isAvailable={isCurrentSelectionAvailable} 
-            isLoading={isCartLoading}
-            onAddToCart={addToCartHandler}
-            onBuyNow={buyNowHandler}
-        />
-    </>);
-}
+      </div>
+      <ProductAddToCart
+        isAvailable={isCurrentSelectionAvailable}
+        isLoading={isCartLoading}
+        onAddToCart={addToCartHandler}
+        onBuyNow={buyNowHandler}
+      />
+    </>
+  );
+}

+ 108 - 53
src/app/(public)/product/_components/ProductMedia.tsx

@@ -1,58 +1,113 @@
 "use client";
 
-import Image from 'next/image'
-import 'swiper/css';
-// import 'swiper/css/pagination';
-import { Swiper, SwiperSlide } from 'swiper/react';
-import { Pagination } from 'swiper/modules';
-import type {Swiper as SwiperType} from 'swiper';
-import type { PaginationOptions } from 'swiper/types';
+import Image from "next/image";
+import "swiper/css";
+import "swiper/css/pagination";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Pagination } from "swiper/modules";
+import type { Swiper as SwiperType } from "swiper";
+import type { PaginationOptions } from "swiper/types";
 import { ProductMediaType } from "@/components/catalog/type";
-import {
-  baseUrl,
-  getImageUrl
-} from "@/utils/constants";
-export function ProductMedia({ mediaData }: {mediaData: Array<ProductMediaType>; }) {
+import { baseUrl, getImageUrl } from "@/utils/constants";
+import { useState, useRef } from "react";
 
-    const pagination: PaginationOptions = {
-        el: '.product-media-swiper-pagination',
-        type: 'custom',
-        renderCustom: function (swiper: SwiperType, current: number, total: number) {
-            return current + '/' + total;
-        },
-    };
-    let mediaJSX = <></>;
-    if(mediaData.length) {
-        mediaJSX = (
-            <Swiper
-                pagination={pagination}
-                modules={[Pagination]}
-                className="product-media-swiper"
-                onSlideChange={() => console.log('slide change')}
-                onSwiper={(swiper) => console.log(swiper)}
-            >
-                {
-                    mediaData.map((img) => {
-                        return (
-                            <SwiperSlide key={img.id}>
-                                <Image className="block w-full"
-                                    src={getImageUrl(img.publicPath,baseUrl)}
-                                    width={390}
-                                    height={520}
-                                    loading="eager"
-                                    alt="Picture of the author"
-                                />
-                            </SwiperSlide>
-                        );
-                    }) 
-                }
-                
-                <div slot="container-end">
-                    <div className="product-media-swiper-pagination z-1 absolute bottom-6 left-1/2 transform-[translateX(-50%)] pt-1.5 pb-1.5 pl-3 pr-3 rounded-3xl text-ly-14 text-white bg-ly-deepgreen/50"></div>
-                </div>
-            </Swiper>
-        );
-    }
-    return mediaJSX;
+export function ProductMedia({
+  mediaData,
+}: {
+  mediaData: Array<ProductMediaType>;
+}) {
+  // 保存大图swiper实例,控制跳转
+  const mainSwiperRef = useRef<SwiperType | null>(null);
+  // 保存缩略图swiper实例,同步滚动
+  const thumbSwiperRef = useRef<SwiperType | null>(null);
+  // 当前激活下标
+  const [activeIndex, setActiveIndex] = useState(0);
 
-}
+  const pagination: PaginationOptions = {
+    el: ".product-media-swiper-pagination",
+    type: "custom",
+    renderCustom: function (
+      swiper: SwiperType,
+      current: number,
+      total: number,
+    ) {
+      return current + "/" + total;
+    },
+  };
+
+  const mediaJSX = mediaData.length ? (
+    <div className="flex flex-col gap-1 w-full">
+      {/* 上方主图轮播 */}
+      <Swiper
+        pagination={pagination}
+        modules={[Pagination]}
+        className="product-media-swiper relative w-full"
+        onSlideChange={(swiper) => {
+          // 1. 更新激活下标
+          setActiveIndex(swiper.activeIndex);
+          // 2. 缩略图同步滚动到当前slide
+          thumbSwiperRef.current?.slideTo(swiper.activeIndex);
+        }}
+        onSwiper={(swiper) => {
+          mainSwiperRef.current = swiper;
+        }}
+      >
+        {mediaData.map((img) => (
+          <SwiperSlide key={img.id}>
+            <Image
+              className="block w-full object-cover"
+              src={getImageUrl(img.publicPath, baseUrl)}
+              width={390}
+              height={520}
+              loading="eager"
+              alt="Product wig image"
+            />
+          </SwiperSlide>
+        ))}
+        {/* 自定义分页数字 */}
+        <div slot="container-end">
+          <div className="product-media-swiper-pagination z-1 absolute top-3 right-4 pt-1.5 pb-1.5 pl-3 pr-3 rounded-3xl text-ly-14 text-white bg-ly-deepgreen/50"></div>
+        </div>
+      </Swiper>
+      <div className=" pl-4">
+        {/* 下方缩略图轮播 */}
+        <Swiper
+          onSwiper={(swiper) => (thumbSwiperRef.current = swiper)}
+          slidesPerView={6} // 固定6个缩略图
+          spaceBetween={8}
+          watchSlidesProgress
+          className="thumb-swiper w-full"
+        >
+          {mediaData.map((img, idx) => (
+            <SwiperSlide key={img.id} className="w-[100px] flex-shrink-0">
+              <div
+                className={`cursor-pointer border-2 rounded overflow-hidden transition-all ${
+                  activeIndex === idx
+                    ? "border-green-600 scale-[1.02]"
+                    : "border-transparent opacity-70 hover:opacity-100"
+                }`}
+                onClick={() => {
+                  // 点击缩略图,主图跳转对应下标
+                  mainSwiperRef.current?.slideTo(idx);
+                }}
+              >
+                <Image
+                  src={getImageUrl(img.publicPath, baseUrl)}
+                  width={100}
+                  height={130}
+                  loading="eager"
+                  alt={`Thumbnail ${idx + 1}`}
+                  className="w-full h-full object-cover"
+                />
+              </div>
+            </SwiperSlide>
+          ))}
+        </Swiper>
+      </div>
+    </div>
+  ) : (
+    <></>
+  );
+
+  return mediaJSX;
+}

+ 13 - 8
src/app/(public)/product/_components/ProductReviewSection.tsx

@@ -2,23 +2,28 @@ import ReviewAdd from "@/app/(public)/product/_components/review/ReviewAdd";
 import ReviewDetail from "@/app/(public)/product/_components/review/ReviewDetail";
 import { NoReview } from "@/app/(public)/product/_components/review/NoReview";
 import { getProductReviews } from "@utils/hooks/getProductReviews";
-
+import type { ProductReviewList } from "@/types/products/productDetail";
+// 组件入参TS规范
+interface ProductReviewSectionProps {
+  productId: string;
+  reviews: ProductReviewList;
+}
 
 export async function ProductReviewSection({
     productId, 
-} : {
-    productId:string;
-}) { 
-    const getAllreviews = await getProductReviews(productId)
+    reviews,
+} : ProductReviewSectionProps) { 
+    // const getAllreviews = await getProductReviews(productId)
+    console.log("productId-------------:",productId,reviews);
     return (
         <>
             <div className="box-border w-full pr-4 pl-4">
                 <ReviewAdd productId={productId} />
-                {getAllreviews.length > 0 ? (
+                {reviews.length > 0 ? (
                   <>
                     <ReviewDetail
-                      reviewDetails={getAllreviews}
-                      totalReview={getAllreviews.length}
+                      reviewDetails={reviews}
+                      totalReview={reviews.length}
                       productId={productId}
                     />
                   </>

+ 1 - 1
src/app/(public)/product/_components/ProductShortDescription.tsx

@@ -8,7 +8,7 @@ export function ProductShortDescription({
     return (
         <>
             <div className="box-border w-full pr-4 pl-4">
-                <h3>Product Description</h3>
+                <h3>Description</h3>
                 <div dangerouslySetInnerHTML={{ __html: shortDescription }}></div>
             </div>
             

+ 48 - 0
src/app/(public)/product/_components/ProductText.tsx

@@ -0,0 +1,48 @@
+"use client";
+import { useState } from "react";
+
+export default function ProductText({ ProductText }: { ProductText: string }) {
+  // 模拟商品长标题
+  const titleText =
+    ProductText ||
+    "Wiggins Hair HD Lace Frontal Wigs straight A Grade Human Hair 13x4 Transparent Lace Front Wig For Women Natural Black 18-30 Inch";
+  const [isExpand, setIsExpand] = useState(false);
+
+  return (
+    <div className="flex mt-2 items-start py-2 pr-2 pl-3 gap-2 w-full bg-[#F9F9F9]">
+      {/* 标题文本容器 */}
+      <div
+        className={`flex-1 text-ly-14 leading-ly-22  font-normal overflow-hidden transition-all ${
+          isExpand ? "" : "line-clamp-1 text-ellipsis whitespace-nowrap"
+        }`}
+      >
+        {titleText}
+      </div>
+
+      {/* 展开/收起SVG按钮 */}
+      <button
+        onClick={() => setIsExpand(!isExpand)}
+        className="shrink-0  transition-transform duration-300"
+        style={{ transform: isExpand ? "rotate(180deg)" : "rotate(0deg)" }}
+      >
+        {/* 向下对勾/箭头SVG */}
+
+        <svg
+          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)"
+            stroke-width="1.5"
+            stroke-linejoin="round"
+            stroke-linecap="square"
+            d="M18 9L12 15L6 9"
+          ></path>
+        </svg>
+      </button>
+    </div>
+  );
+}

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 158 - 0
src/app/(public)/product/_components/PromotionDiscountCardProps .tsx


+ 66 - 0
src/app/(public)/product/_components/SwitchButton.tsx

@@ -0,0 +1,66 @@
+"use client";
+import { useState } from "react";
+import { ProductReviewSection } from "@/app/(public)/product/_components/ProductReviewSection";
+// 后续你开发问答组件后引入
+// import ProductQuestionSection from "./ProductQuestionSection";
+
+interface ReviewQuestionTabProps {
+  productId: string;
+  reviewList: any[]; // 替换为你的 ProductReviewList 类型
+  reviewTotal: number; // 评论总数 (allReviews.length)
+  questionTotal: number; // 问答总数,后端接口获取
+}
+
+export default function SwitchButton({
+  productId,
+  reviewList,
+  reviewTotal,
+  questionTotal,
+}: ReviewQuestionTabProps) {
+  // 切换状态:默认激活评论
+  const [activeTab, setActiveTab] = useState<"review" | "question">("review");
+
+  return (
+    <div className="w-full mt-6">
+      {/* 两个Tab按钮,还原截图圆角样式 */}
+      <div className="flex gap-4">
+        {/* Reviews 标签 */}
+        <button
+          onClick={() => setActiveTab("review")}
+          className={`px-10 py-4 rounded-full text-2xl transition-all ${
+            activeTab === "review"
+              ? "bg-black text-white"
+              : "bg-white border border-black text-black"
+          }`}
+        >
+          Reviews ({reviewTotal})
+        </button>
+
+        {/* Questions 标签 */}
+        <button
+          onClick={() => setActiveTab("question")}
+          className={`px-10 py-4 rounded-full text-2xl transition-all ${
+            activeTab === "question"
+              ? "bg-black text-white"
+              : "bg-white border border-black text-black"
+          }`}
+        >
+          Questions ({questionTotal})
+        </button>
+      </div>
+
+      {/* 分割线 */}
+      <div className="w-full h-[1px] bg-black mt-4 mb-6"></div>
+
+      {/* 根据tab切换渲染对应内容 */}
+      <div>
+        {activeTab === "review" ? (
+          <ProductReviewSection reviews={reviewList} productId={productId} />
+        ) : (
+          // 这里替换为你的问答组件
+          <div>Product Question Section 待开发</div>
+        )}
+      </div>
+    </div>
+  );
+}

+ 73 - 0
src/app/(public)/product/_components/popup/InstallmentPopup.tsx

@@ -0,0 +1,73 @@
+"use client";
+import Image from "next/image";
+import { useEffect } from "react";
+
+interface InstallmentPopup {
+  open: boolean;
+  onClose: () => void;
+}
+
+export default function InstallmentPopup({ open, onClose }: InstallmentPopup) {
+  // 弹窗打开时锁定滚动
+  useEffect(() => {
+    if (open) document.body.style.overflow = "hidden";
+    else document.body.style.overflow = "";
+    return () => {
+      document.body.style.overflow = "";
+    };
+  }, [open]);
+
+  // 点击遮罩关闭
+  const handleMaskClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) onClose();
+  };
+
+  if (!open) return null;
+
+  return (
+    <div
+      onClick={handleMaskClick}
+      className="fixed inset-0 bg-black/60 z-[9999] flex items-center justify-center p-4"
+    >
+      {/* 弹窗容器 */}
+      <div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden">
+        {/* 右上角关闭按钮 */}
+        <button
+          onClick={onClose}
+          className="absolute top-3 right-3 w-8 h-8 rounded-full bg-white/80 flex items-center justify-center text-black text-xl z-10 hover:bg-white transition"
+        >
+          <svg
+            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)"
+              stroke-width="1.2"
+              stroke-linejoin="round"
+              stroke-linecap="round"
+              d="M7 7L17 17"
+            ></path>
+            <path
+              stroke="rgba(0, 0, 0, 1)"
+              stroke-width="1.2"
+              stroke-linejoin="round"
+              stroke-linecap="round"
+              d="M7 17L17 7"
+            ></path>
+          </svg>
+        </button>
+        {/* 弹窗介绍图,替换为你的实际图片地址 */}
+        <Image
+          src="/images/bnpl-modal-info.png"
+          alt="Buy Now Pay Later Info"
+          width={960}
+          height={1307}
+          className="w-full h-auto block"
+        />
+      </div>
+    </div>
+  );
+}

+ 1 - 0
src/app/(public)/product/_components/review/ReviewDetail.tsx

@@ -38,6 +38,7 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
   return (
     <>
       <div className="flex flex-col flex-wrap gap-x-5 sm:gap-x-10">
+        <h1>cscscscscs</h1>
         <div className="my-2 flex w-full flex-col flex-wrap justify-between gap-4 sm:flex-row sm:items-center min-[1350px]:flex-nowrap">
           <div className="flex items-center gap-x-2">
             <Rating

+ 35 - 17
src/app/(public)/search/_components/HotSearch.tsx

@@ -2,21 +2,43 @@
 
 import Link from 'next/link';
 import { createUrl } from '@/utils/helper';
-import { useSearchParams } from 'next/navigation';
 
 const hotTags = [
-  'HD Wig',
-  'Glueless Wig',
-  'New Arrival',
-  'Color Wig',
-  'Lace Front Wig',
-  'Bundles',
-  'M Hairline',
-  '13x6',
+  {
+    name: 'HD Wig',
+    href: '/search?q=HD%20Wig'
+  },
+  {
+    name: 'Glueless Wig',
+    href: '/search?q=Glueless%20Wig'
+  },
+  {
+    name: 'New Arrival',
+    href: '/search?q=New%20Arrival'
+  },
+  {
+    name: 'Color Wig',
+    href: '/search?q=Color%20Wig'
+  },
+  {
+    name: 'Lace Front Wig',
+    href: '/search?q=Lace%20Front%20Wig'
+  },
+  {
+    name: 'Bundles',
+    href: '/search?q=Bundles'
+  },
+  {
+    name: 'M Hairline',
+    href: '/search?q=M%20Hairline'
+  },
+  {
+    name: '13x6',
+    href: '/search?q=13x6'
+  },
 ];
 
 export default function HotSearch() {
-  const searchParams = useSearchParams();
 
   return (
     <div className="w-full max-w-screen-2xl mx-auto px-4 py-6">
@@ -24,17 +46,13 @@ export default function HotSearch() {
       <div className="flex flex-wrap items-center justify-start gap-3">
         {hotTags.map((tag) => {
           // 构造带q参数的搜索url
-          const params = new URLSearchParams(searchParams.toString());
-          params.set('q', tag);
-          const tagUrl = createUrl('/search', params);
-
           return (
             <Link
-              key={tag}
-              href={tagUrl}
+              key={tag.name}
+              href={tag.href}
               className="bg-[#F2F2F2FF] rounded-sm px-[15px] py-1.5 flex items-center justify-center text-xs text-black font-normal"
             >
-              {tag}
+              {tag.name}
             </Link>
           );
         })}

+ 106 - 68
src/app/(public)/search/page.tsx

@@ -2,21 +2,29 @@ import dynamicImport from "next/dynamic";
 import Grid from "@/components/theme/ui/grid/Grid";
 import NotFound from "@/components/theme/search/not-found";
 import { isArray } from "@/utils/type-guards";
-import {serverGraphqlFetch} from "@utils/bagisto/index";
-import { GET_FILTER_PRODUCTS,SEARCH_PRODUCTS } from "@/graphql";
-import { GET_PRODUCTS, GET_PRODUCTS_PAGINATION } from "@/graphql";
+import { serverGraphqlFetch } from "@utils/bagisto/index";
+import {
+  GET_PRODUCTS,
+  GET_PRODUCTS_PAGINATION,
+  GET_FILTER_PRODUCTS,
+  SEARCH_PRODUCTS,
+} from "@/graphql";
 import {
   cachedGraphQLRequest,
-  getFilterAttributes,
+  // getFilterAttributes,
 } from "@/utils/hooks/useCache";
 import { generateMetadataForPage, buildProductFilters } from "@/utils/helper";
 import SortOrder from "@/components/theme/filters/SortOrder";
 import { SortByFields } from "@/utils/constants";
-import MobileFilter from "@/components/theme/filters/MobileFilter";
+// import MobileFilter from "@/components/theme/filters/MobileFilter";
 import FilterList from "@/components/theme/filters/FilterList";
-import { ProductsResponse,SearchProductsResponse, SearchProductsVariables  } from "@/components/catalog/type";
+import {
+  ProductsResponse,
+  SearchProductsResponse,
+  SearchProductsVariables,
+} from "@/components/catalog/type";
 import { MobileSearchBar } from "@components/layout/navbar/MobileSearch";
-import HotSearch from "./_components/HotSearch"
+import HotSearch from "./_components/HotSearch";
 const Pagination = dynamicImport(
   () => import("@/components/catalog/Pagination"),
 );
@@ -25,13 +33,14 @@ const ProductGridItems = dynamicImport(
 );
 
 export const dynamicParams = true;
-
+// 获取全站商品
 export async function generateStaticParams() {
   try {
-    const itemsPerPage = 12;
+    const itemsPerPage = 8;
     const commonSearches = [""];
     const params = [];
     for (const query of commonSearches) {
+      // 获取默认产品回头换成 you may like 的产品
       const { data } = await cachedGraphQLRequest<ProductsResponse>(
         "search",
         GET_PRODUCTS,
@@ -79,7 +88,7 @@ export async function generateStaticParams() {
     return [];
   }
 }
-
+// SEO+分享
 export async function generateMetadata({
   searchParams,
 }: {
@@ -111,16 +120,7 @@ export default async function SearchPage({
   } = (params || {}) as {
     [key: string]: string;
   };
-const searchRes = await serverGraphqlFetch<SearchProductsResponse,SearchProductsVariables>({
-          query: SEARCH_PRODUCTS,
-          variables: {
-              "suggest": true,
-              "query":  "reaady",
-              "first": 20
-          }
-         });
-  console.log('ssssss ------ ', searchRes);
-  const itemsPerPage = 12;
+  const itemsPerPage = 8;
   const currentPage = page ? parseInt(page) - 1 : 0;
   const sortValue = params?.sort || "name-asc";
   const selectedSort =
@@ -132,81 +132,119 @@ const searchRes = await serverGraphqlFetch<SearchProductsResponse,SearchProducts
 
   let dataPromise;
   if (isFilterApplied) {
-    dataPromise = cachedGraphQLRequest<ProductsResponse>(
-      "search",
-      GET_FILTER_PRODUCTS,
-      {
+    dataPromise = await serverGraphqlFetch<
+      SearchProductsResponse,
+      SearchProductsVariables
+    >({
+      query: SEARCH_PRODUCTS,
+      variables: {
+        suggest: true,
         query: searchValue,
         filter: filterInput,
+        first: 8,
         ...(beforeCursor
           ? { last: itemsPerPage, before: beforeCursor }
           : { first: itemsPerPage, after: afterCursor }),
         sortKey: selectedSort.sortKey,
         reverse: selectedSort.reverse,
       },
-    );
+    });
+    //  dataPromise = cachedGraphQLRequest<ProductsResponse>(
+    //     "search",
+    //     GET_FILTER_PRODUCTS,
+    //     {
+    //       query: searchValue,
+    //       filter: filterInput,
+    //       ...(beforeCursor
+    //         ? { last: itemsPerPage, before: beforeCursor }
+    //         : { first: itemsPerPage, after: afterCursor }),
+    //       sortKey: selectedSort.sortKey,
+    //       reverse: selectedSort.reverse,
+    //     },
+    //   );
   } else {
     dataPromise = (async () => {
       let currentAfterCursor: string | undefined = afterCursor;
+      //  console.log("currentAfterCursoraaaaa00:",currentAfterCursor);
       if (currentPage > 0 && !afterCursor) {
-        const { data: cursorData } =
-          await cachedGraphQLRequest<ProductsResponse>(
-            "search",
-            GET_PRODUCTS_PAGINATION,
-            {
-              query: searchValue,
-              first: currentPage * itemsPerPage,
-              sortKey: selectedSort.sortKey,
-              reverse: selectedSort.reverse,
-            },
-          );
-        currentAfterCursor = cursorData?.products?.pageInfo?.endCursor;
+        // 兜底处理 如果用户直接通过url访问且没有在url上带cursor=  会调接口获取cursor值  
+        // 正常走底部分页按钮不会触发该接口调用;
+        const { data: cursorData } = await serverGraphqlFetch<
+          SearchProductsResponse,
+          SearchProductsVariables
+        >({
+          query: SEARCH_PRODUCTS,
+          variables: {
+            suggest: true,
+            query: searchValue,
+            first: currentPage * itemsPerPage,
+            sortKey: selectedSort.sortKey,
+            reverse: selectedSort.reverse,
+          },
+        });
+        // await cachedGraphQLRequest<ProductsResponse>(
+        //   "search",
+        //   GET_PRODUCTS_PAGINATION,
+        //   {
+        //     query: searchValue,
+        //     first: currentPage * itemsPerPage,
+        //     sortKey: selectedSort.sortKey,
+        //     reverse: selectedSort.reverse,
+        //   },
+        // );
+        currentAfterCursor = cursorData?.search?.pageInfo?.endCursor;
+        // console.log("currentAfterCursoraaaaa:",currentAfterCursor);
       }
-
-      return cachedGraphQLRequest<ProductsResponse>("search", GET_PRODUCTS, {
-        query: searchValue,
-        ...(beforeCursor
-          ? { last: itemsPerPage, before: beforeCursor }
-          : { first: itemsPerPage, after: currentAfterCursor }),
-        sortKey: selectedSort.sortKey,
-        reverse: selectedSort.reverse,
+      return serverGraphqlFetch<
+        SearchProductsResponse,
+        SearchProductsVariables
+      >({
+        query: SEARCH_PRODUCTS,
+        variables: {
+          suggest: true,
+          query: searchValue,
+          ...(beforeCursor
+            ? { last: itemsPerPage, before: beforeCursor }
+            : { first: itemsPerPage, after: currentAfterCursor }),
+          sortKey: selectedSort.sortKey,
+          reverse: selectedSort.reverse,
+        },
       });
+      //    cachedGraphQLRequest<ProductsResponse>("search", GET_PRODUCTS, {
+      //   query: searchValue,
+      //   ...(beforeCursor
+      //     ? { last: itemsPerPage, before: beforeCursor }
+      //     : { first: itemsPerPage, after: currentAfterCursor }),
+      //   sortKey: selectedSort.sortKey,
+      //   reverse: selectedSort.reverse,
+      // });
     })();
   }
 
-  const [{ data }, filterAttributes] = await Promise.all([
-    dataPromise,
-    getFilterAttributes(),
-  ]);
-
-  const products = data?.products?.edges?.map((e) => e.node) || [];
-  const pageInfo = data?.products?.pageInfo;
-  const totalCount = data?.products?.totalCount || 0;
+  // const [{ data }, filterAttributes] = await Promise.all([
+  //   dataPromise,
+  //   getFilterAttributes(),
+  // ]);
+  const { data } = await dataPromise;
+  console.log("sssssssssssssssssssssssssss:", data);
 
+  const products = data?.search?.edges?.map((e) => e.node) || [];
+  const pageInfo = data?.search?.pageInfo;
+  const totalCount = data?.search?.totalCount || 0;
+  console.log("productssssssssssssssssssss---------------------",products);
+  
   return (
     <>
       <MobileSearchBar />
       <HotSearch />
-      {/* <div className="my-10 hidden gap-4 md:flex md:items-baseline md:justify-between w-full mx-auto max-w-screen-2xl px-4 xss:px-7.5">
-        <FilterList filterAttributes={filterAttributes} />
-
-        <SortOrder sortOrders={SortByFields} title="Sort by" />
-      </div> */}
       {isArray(products) ? (
-        <div className="flex items-center justify-between gap-4 py-8 md:hidden  mx-auto w-full max-w-screen-2xl px-4 xss:px-7.5">
-          <MobileFilter filterAttributes={filterAttributes} />
-
+        <div className="flex items-center justify-between gap-4 pt-0 pb-6 md:hidden  mx-auto w-full max-w-screen-2xl px-4 xss:px-7.5">
+          {/* <MobileFilter filterAttributes={filterAttributes} /> */}
+          <div>{totalCount||0} items</div>
           <SortOrder sortOrders={SortByFields} title="Sort by" />
         </div>
       ) : null}
       {!isArray(products) && (
-        // <NotFound
-        //   msg={`${
-        //     searchValue
-        //       ? `There are no products that match Showing : ${searchValue}`
-        //       : "There are no products that match Showing"
-        //   } `}
-        // />
         <div>
           <h2>There are no products that match Showing : ${searchValue}</h2>
           <h2 className="text-2xl sm:text-4xl font-semibold mx-auto mt-7.5 w-full max-w-screen-2xl my-3 mx-auto px-4 xss:px-7.5">

+ 36 - 0
src/components/layout/navbar/NavbarSearchIcon.tsx

@@ -0,0 +1,36 @@
+"use client";
+import { useState } from "react";
+import SearchPop from "./SearchPop";
+export default function NavbarSearchIcon() {
+  const [searchOpen, setSearchOpen] = useState(false);
+  return (
+    <>
+      <div
+        className="w-6 h-6 flex items-center justify-center"
+        onClick={() => setSearchOpen(true)}
+      >
+        <svg
+          xmlns="http://www.w3.org/2000/svg"
+          width="17.7802734375"
+          height="18.7802734375"
+          viewBox="0 0 17.7802734375 18.7802734375"
+          fill="none"
+        >
+          <circle
+            cx="8.75"
+            cy="8.75"
+            r="8"
+            stroke="rgba(0, 0, 0, 1)"
+            stroke-width="1.5"
+          ></circle>
+          <path
+            stroke="rgba(0, 0, 0, 1)"
+            stroke-width="1.5"
+            d="M14.25 15.25L17.25 18.25"
+          ></path>
+        </svg>
+      </div>
+      <SearchPop open={searchOpen} onClose={() => setSearchOpen(false)} />
+    </>
+  );
+}

+ 178 - 0
src/components/layout/navbar/SearchPop.tsx

@@ -0,0 +1,178 @@
+"use client";
+import { useState, useEffect, useRef } from "react";
+import { useSearchProduct } from "@/utils/hooks/useSearchProduct";
+import Grid from "@/components/theme/ui/grid/Grid";
+import dynamicImport from "next/dynamic";
+import HotSearch from "@/app/(public)/search/_components/HotSearch"
+import { useMediaQuery } from "@utils/hooks/useMediaQueryHook";
+import Link from "next/link";
+const ProductGridItems = dynamicImport(
+  () => import("@/components/catalog/product/ProductGridItems"),
+);
+
+// 定义弹窗入参类型
+interface SearchPopProps {
+  open: boolean;
+  onClose: () => void;
+}
+
+export default function SearchPop({ open, onClose }: SearchPopProps) {
+  const { data, loading, error, searchProducts } = useSearchProduct();
+  const [keyword, setKeyword] = useState("");
+  const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
+
+  const products = data?.edges?.map((e) => e.node) || [];
+  const isDesktop = useMediaQuery("(min-width: 1024px)");
+
+  // 执行搜索接口
+  const triggerSearch = async (searchWord: string) => {
+    if (!searchWord.trim()) return;
+    const params = {
+      suggest: true,
+      query: searchWord,
+      first: 8,
+    };
+    console.log("【接口入参 variables】", params);
+    const res = await searchProducts(params);
+    console.log("【接口完整返回结果】", res);
+    console.log("【商品列表数据】", res.data?.search);
+  };
+
+  // 防抖输入
+  const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const value = e.target.value;
+    setKeyword(value);
+    if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    debounceTimerRef.current = setTimeout(() => triggerSearch(value), 300);
+  };
+
+  // 点击搜索/回车搜索
+  const handleSearch = () => {
+    if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    triggerSearch(keyword);
+  };
+
+  // 销毁清除定时器
+  useEffect(() => {
+    return () => {
+      if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
+    };
+  }, []);
+
+  // 轮播占位文案
+  const placeholderList = [
+    "Search for products...",
+    "Find best sellers",
+    "Search new arrivals",
+    "Search discount items",
+    "Search trending products",
+  ];
+  const [placeholderIndex, setPlaceholderIndex] = useState(0);
+  useEffect(() => {
+    const interval = setInterval(() => {
+      setPlaceholderIndex((prev) => (prev + 1) % placeholderList.length);
+    }, 3000);
+    return () => clearInterval(interval);
+  }, [placeholderList.length]);
+  const currentPlaceholder = placeholderList[placeholderIndex];
+
+  // 关闭弹窗并清空关键词
+  const handleClose = () => {
+    setKeyword("");
+    onClose();
+  };
+
+  // 弹窗未打开直接返回null
+  if (!open) return null;
+
+  return (
+    // 全屏弹窗根容器,纯div无多余大括号
+    <div className="fixed inset-0 z-[9999] bg-white overflow-y-auto">
+      {/* 顶部搜索栏区域 */}
+      <div className="relative flex items-center justify-center px-4 py-4 border-b border-neutral-200 pt-10">
+        {/* 右上角关闭按钮 严格24px w-6 h-6 */}
+        <button
+          onClick={handleClose}
+          className="absolute right-4 top-4 w-6 h-6 flex items-center justify-center cursor-pointer"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            fill="none"
+            viewBox="0 0 24 24"
+            strokeWidth={1.8}
+            stroke="currentColor"
+            className="w-full h-full text-neutral-700"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              d="M6 18L18 6M6 6l12 12"
+            />
+          </svg>
+        </button>
+      </div>
+      {/* 搜索输入框容器 */}
+      <div className="block w-full justify-center md:hidden px-4 mt-3 z-10">
+        <div
+          className={`${isDesktop ? "max-w-[550px]" : ""} relative w-full mx-auto xl:min-w-[516px] outline-none hover:outline-none`}
+        >
+          <input
+            onChange={handleKeywordChange}
+            onKeyDown={(e) => e.key === "Enter" && handleSearch()}
+            autoComplete="off"
+            className="input w-full rounded-lg border border-neutral-200 bg-white py-2 pl-3 pr-10 text-sm text-black outline-none placeholder:text-neutral-500 focus:ring-2 focus:ring-neutral-300 dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400 md:pl-4"
+            name="search"
+            placeholder={currentPlaceholder}
+            type="text"
+            value={keyword}
+          />
+          {/* 搜索图标按钮 */}
+          <div
+            onClick={handleSearch}
+            className="absolute bottom-0 right-1 top-0 flex w-9 cursor-pointer items-center justify-center border-l border-neutral-200 dark:border-neutral-700 md:border-0"
+          >
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              fill="none"
+              viewBox="0 0 24 24"
+              strokeWidth="1.5"
+              className="size-5 stroke-neutral-500 dark:stroke-white md:stroke-black"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
+              />
+            </svg>
+          </div>
+        </div>
+      </div>
+      {/* hot search */}
+      <HotSearch />
+      {/* 商品列表内容区 */}
+      <div >
+        {error && <p className="text-red-500 my-2 text-center">{error}</p>}
+        {data?.edges?.length === 0 && keyword && (
+          <p className="text-center text-gray-500 my-6">No matching products</p>
+        )}
+        {keyword && data?.edges?.length !== 0 && (
+          <div>
+            <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="flex justify-center mt-10 mb-16">
+              <Link
+                href={`/search?q=${encodeURIComponent(keyword)}`}
+                onClick={handleClose}
+                className="inline-block border border-black rounded-full px-12 py-4 text-xl font-normal text-black hover:bg-gray-100 transition-colors"
+              >
+                View More
+              </Link>
+            </div>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

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

@@ -12,7 +12,7 @@ import { NavbarErrorBoundary } from "@/components/error/ErrorBoundary";
 import { GET_TREE_CATEGORIES } from "@/graphql";
 import { cachedGraphQLRequest } from "@utils/hooks/useCache";
 import { TreeCategoriesResponse } from "@/types/theme/category-tree";
-
+import NavbarSearchIcon from "./NavbarSearchIcon"
 export default async function Navbar() {
   const { data } = await cachedGraphQLRequest<TreeCategoriesResponse>(
     "category",
@@ -54,28 +54,7 @@ export default async function Navbar() {
                 {/* <Suspense fallback={<SearchSkeleton />}>
                   <Search search={false} />
                 </Suspense> */}
-                <Link className="w-6 h-6 flex items-center justify-center" href={"/search?q"}>
-                  <svg
-                    xmlns="http://www.w3.org/2000/svg"
-                    width="17.7802734375"
-                    height="18.7802734375"
-                    viewBox="0 0 17.7802734375 18.7802734375"
-                    fill="none"
-                  >
-                    <circle
-                      cx="8.75"
-                      cy="8.75"
-                      r="8"
-                      stroke="rgba(0, 0, 0, 1)"
-                      stroke-width="1.5"
-                    ></circle>
-                    <path
-                      stroke="rgba(0, 0, 0, 1)"
-                      stroke-width="1.5"
-                      d="M14.25 15.25L17.25 18.25"
-                    ></path>
-                  </svg>
-                </Link>
+              <NavbarSearchIcon/>
               </div>
             </div>
             <Link

+ 2 - 2
src/graphql/catalog/queries/SearchProducts.ts

@@ -1,8 +1,8 @@
 import { gql,TypedDocumentNode } from "@apollo/client";
 import { PRODUCT_CORE_FRAGMENT } from "../fragments";
-import { SearchProductsResponse } from "@/components/catalog/type";
+import { SearchProductsResponse,SearchProductsVariables } from "@/components/catalog/type";
 
-export const SEARCH_PRODUCTS: TypedDocumentNode<SearchProductsResponse> = gql`
+export const SEARCH_PRODUCTS: TypedDocumentNode<SearchProductsResponse,SearchProductsVariables> = gql`
   ${PRODUCT_CORE_FRAGMENT}
 
   query SearchProducts(

+ 20 - 0
src/types/products/productDetail.ts

@@ -0,0 +1,20 @@
+/**
+ * 单条评论主体 Node 结构 ProductReview
+ */
+export interface ProductReviewNode {
+  name: string; // 评论人昵称
+  title: string; // 标题/发长
+  rating: number; // 星级 1-5
+  comment: string; // 评论正文
+  createdAt: string; // ISO时间字符串
+  __typename: "ProductReview";
+}
+export interface ProductReviewEdge {
+  node: ProductReviewNode;
+  __typename: "ProductReviewEdge";
+}
+
+/**
+ * 整体:评论数组 = ProductReviewEdge[]
+ */
+export type ProductReviewList = ProductReviewEdge[];

+ 80 - 0
src/utils/hooks/useSearchProduct.ts

@@ -0,0 +1,80 @@
+"use client";
+import { useState, useRef } from "react";
+import { useApolloClient } from "@apollo/client/react";
+import { SEARCH_PRODUCTS } from "@/graphql";
+import type { SearchProductsResponse, SearchProductsVariables } from "@/components/catalog/type";
+
+interface SearchProductResult {
+  data: SearchProductsResponse | null;
+  msg: string;
+  error: boolean;
+}
+
+export function useSearchProduct() {
+  const apolloClient = useApolloClient();
+  const [data, setData] = useState<SearchProductsResponse["search"] | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const initRef = useRef(true);
+
+  const searchProducts = async (variables: SearchProductsVariables): Promise<SearchProductResult> => {
+    if (!initRef.current) {
+      setLoading(true);
+    }
+
+    try {
+      const searchRes = await apolloClient.query<SearchProductsResponse, SearchProductsVariables>({
+        query: SEARCH_PRODUCTS,
+        variables,
+        fetchPolicy: "no-cache",
+        context: {
+          fetchOptions: {
+            cache: "no-store",
+          },
+        },
+      });
+
+      console.log("ssssss ------ ", searchRes);
+
+      if (initRef.current) initRef.current = false;
+
+      // 关键修复:undefined 转 null
+      const result: SearchProductResult = {
+        data: searchRes.data ?? null,
+        msg: "",
+        error: false,
+      };
+
+      if (result.data) {
+        setData(result.data.search);
+      } else {
+        setData(null);
+      }
+      setError(null);
+      setLoading(false);
+      return result;
+    } catch (err: any) {
+      if (initRef.current) initRef.current = false;
+      console.error("search product query error: ", err);
+
+      const errMsg = err?.message || "Search failed, please try again";
+      const result: SearchProductResult = {
+        data: null,
+        msg: errMsg,
+        error: true,
+      };
+
+      setError(errMsg);
+      setData(null);
+      setLoading(false);
+      return result;
+    }
+  };
+
+  return {
+    data,
+    loading,
+    error,
+    searchProducts,
+  };
+}