فهرست منبع

产品详情页调整

zhangzf 3 روز پیش
والد
کامیت
ae92db09ef
31فایلهای تغییر یافته به همراه3383 افزوده شده و 227 حذف شده
  1. BIN
      public/image/products_details/guide.png
  2. BIN
      public/image/products_details/installment_description.png
  3. BIN
      public/image/products_details/question.png
  4. BIN
      public/image/products_details/reviews.png
  5. 7 1
      src/app/(public)/product/[...urlProduct]/page.tsx
  6. 181 24
      src/app/(public)/product/_components/ProductAddToCart.tsx
  7. 20 6
      src/app/(public)/product/_components/ProductInformation.tsx
  8. 42 28
      src/app/(public)/product/_components/ProductReviewSection.tsx
  9. 148 0
      src/app/(public)/product/_components/ShopServicePanel.tsx
  10. 123 19
      src/app/(public)/product/_components/SwitchButton.tsx
  11. 81 0
      src/app/(public)/product/_components/popup/Guide.tsx
  12. 36 28
      src/app/(public)/product/_components/popup/InstallmentPopup.tsx
  13. 102 0
      src/app/(public)/product/_components/popup/PriceModal.tsx
  14. 173 0
      src/app/(public)/product/_components/popup/ShoppingSecurityModal.tsx
  15. 69 0
      src/app/(public)/product/_components/popup/VipPlusModal.tsx
  16. 70 0
      src/app/(public)/product/_components/question/AskQuestionModal.tsx
  17. 153 0
      src/app/(public)/product/_components/question/ComponentsQuestionModal.tsx
  18. 144 0
      src/app/(public)/product/_components/question/FaqList.tsx
  19. 390 0
      src/app/(public)/product/_components/question/QAModal.tsx
  20. 98 58
      src/app/(public)/product/_components/review/AddProductReview.tsx
  21. 247 0
      src/app/(public)/product/_components/review/AddProductReviewForm.tsx
  22. 53 0
      src/app/(public)/product/_components/review/HairPhotoGallery.tsx
  23. 103 0
      src/app/(public)/product/_components/review/QuestionInputModal.tsx
  24. 40 16
      src/app/(public)/product/_components/review/ReviewAdd.tsx
  25. 130 43
      src/app/(public)/product/_components/review/ReviewDetail.tsx
  26. 97 0
      src/app/(public)/product/_components/review/ReviewImageGalleryModal.tsx
  27. 104 0
      src/app/(public)/product/_components/review/ReviewImageUploader.tsx
  28. 440 0
      src/app/(public)/product/_components/review/ReviewModal.tsx
  29. 277 0
      src/app/(public)/product/_components/youmaylike/Recommend.tsx
  30. 5 4
      src/components/common/button/ReviewButton.tsx
  31. 50 0
      src/types/products/review.ts

BIN
public/image/products_details/guide.png


BIN
public/image/products_details/installment_description.png


BIN
public/image/products_details/question.png


BIN
public/image/products_details/reviews.png


+ 7 - 1
src/app/(public)/product/[...urlProduct]/page.tsx

@@ -22,6 +22,8 @@ import { ProductInformation } from "@/app/(public)/product/_components/ProductIn
 import { ProductShortDescription } from "@/app/(public)/product/_components/ProductShortDescription";
 import { ProductDetail } from "@/app/(public)/product/_components/ProductDetail";
 import SwitchButton from "../_components/SwitchButton";
+import ShopServicePanel from "../_components/ShopServicePanel";
+import Recommend from "../_components/youmaylike/Recommend"
 import { formatFlexibleVariants } from "@/utils/variantTools";
 
 // export const dynamic = 'auto'
@@ -122,18 +124,22 @@ export default async function ProductPage({
             flexibleVariants={flexibleVariants}
             isSaleable={product.isSaleable}
           />
+          <ShopServicePanel />
           <ProductShortDescription
             shortDescription={product.shortDescription || ""}
           />
-          <ProductDetail detail={product.description || ""} />
+
           <SwitchButton
             productId={String(product._id)}
             reviewList={allReviews}
             reviewTotal={allReviews.length}
             questionTotal={questionTotal}
+            questionList={["aaa"]}
           />
+          <ProductDetail detail={product.description || ""} />
           {/* <ProductReviewSection reviews={allReviews} productId={String(product._id)} /> */}
         </Suspense>
+        <Recommend/>
       </div>
 
       <Suspense fallback={<RelatedProductSkeleton />}>

+ 181 - 24
src/app/(public)/product/_components/ProductAddToCart.tsx

@@ -1,30 +1,187 @@
 "use client";
 
 import clsx from "clsx";
+import { useState, useEffect, useCallback } from "react";
+import PriceModal from "./popup/PriceModal";
+
+// 价格组件复用你项目自带的Price
+import { Price } from "@components/theme/ui/Price";
+
+// SVG图标:左侧对话图标、右侧下拉箭头
+const ChatSvg = () => (
+  <svg
+    xmlns="http://www.w3.org/2000/svg"
+    width="24"
+    height="24"
+    viewBox="0 0 24 24"
+    fill="none"
+  >
+    <path
+      d="M22 12C22 17.5229 17.5229 22 12 22C9.01325 22 2 22 2 22C2 22 2 14.5361 2 12C2 6.47715 6.47717 2 12 2C17.5229 2 22 6.47715 22 12Z"
+      stroke="rgba(0, 0, 0, 1)"
+      stroke-width="1.5"
+      stroke-linejoin="round"
+      stroke-linecap="round"
+    ></path>
+    <path
+      stroke="rgba(0, 0, 0, 1)"
+      stroke-width="1.5"
+      stroke-linejoin="round"
+      stroke-linecap="round"
+      d="M7 9L16 9"
+    ></path>
+    <path
+      stroke="rgba(0, 0, 0, 1)"
+      stroke-width="1.5"
+      stroke-linejoin="round"
+      stroke-linecap="round"
+      d="M7 13L16 13"
+    ></path>
+    <path
+      stroke="rgba(0, 0, 0, 1)"
+      stroke-width="1.5"
+      stroke-linejoin="round"
+      stroke-linecap="round"
+      d="M7 17L12 17"
+    ></path>
+  </svg>
+);
+
+const ArrowDownSvg = () => (
+  <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>
+);
+
+// 价格数据类型定义,从父组件传入
+type VariantPriceInfo = {
+  totalNowPrice: number; // 现价 $119
+  totalLinePrice: number; // 划线原价 $170
+  save: number; // 优惠金额 $51
+};
 
 export function ProductAddToCart({
-    isAvailable,
-    isLoading,
-    onAddToCart,
-    onBuyNow
-}: { 
-    isAvailable: boolean; 
-    isLoading: boolean;
-    onAddToCart: () => void;
-    onBuyNow: () => void;
-}) { 
-    const btnClass = 'flex-none flex items-center justify-center w-40 h-11.5 rounded-4xl text-ly-16 font-bold text-white';
-    return (
-        <div className="fixed w-full bottom-0 left-0 bg-white box-border pt-3 pl-3 pr-3 pb-5 border-t border-solid border-ly-middlegray z-1000">
-            {isAvailable ? (
-                <div className="flex justify-between">
-                    <button onClick={onBuyNow} type="button" className={clsx(btnClass,'bg-ly-deepgreen', { 'opacity-25':isLoading})}>Buy Now</button>
-                    <button onClick={onAddToCart} type="button" className={clsx(btnClass,'bg-ly-middlegreen', { 'opacity-25':isLoading})}>Add To Cart</button>
-                </div>
-            ): (
-                <p>The selected options are not available!</p>
-            )}
-            
+  isAvailable,
+  isLoading,
+  onAddToCart,
+  onBuyNow,
+  priceInfo, // 父组件传入价格数据
+}: {
+  isAvailable: boolean;
+  isLoading: boolean;
+  onAddToCart: () => void;
+  onBuyNow: () => void;
+  priceInfo: VariantPriceInfo; // 价格数据源
+}) {
+  const btnClass =
+    "flex items-center justify-center rounded-full text-ly-16 font-bold text-white h-[46px]";
+  // 滚动控制显示
+  const [isShowBar, setIsShowBar] = useState(false);
+  const SCROLL_THRESHOLD = 200;
+  const [couponModalOpen, setCouponModalOpen] = useState(false);
+  const handleScroll = useCallback(() => {
+    const scrollY = window.scrollY;
+    setIsShowBar(scrollY >= SCROLL_THRESHOLD);
+  }, []);
+
+  useEffect(() => {
+    window.addEventListener("scroll", handleScroll);
+    return () => window.removeEventListener("scroll", handleScroll);
+  }, [handleScroll]);
+
+  // 解构价格数据
+  const { totalNowPrice, totalLinePrice, save } = priceInfo;
+  // 是否有折扣(原价≠现价)
+  const hasDiscount = totalLinePrice !== totalNowPrice;
+
+  return (
+    <div
+      className={clsx(
+        "fixed w-full left-0 bg-white box-border pt-3 px-4 pb-6 border-t border-solid border-ly-middlegray z-[1000] transition-all duration-300 ease-in-out",
+        isShowBar ? "bottom-0" : "-bottom-[160px]",
+      )}
+    >
+      {/* 顶部价格区域 */}
+      <div className="flex items-center justify-between mb-4 w-full">
+        {/* 划线原价(左侧) */}
+        {hasDiscount && (
+          <Price
+            className="text-ly-gray  text-base line-through "
+            amount={String(totalLinePrice)}
+            currencyCode="USD"
+          />
+        )}
+
+        <div className="flex items-center justify-end ">
+          {/* 优惠标签黄色块 */}
+          {hasDiscount && (
+            <Price
+              className="bg-ly-gold px-2  leading-ly-22  text-sm mr-2  font-bold"
+              amount={`-${save}`}
+              currencyCode="USD"
+            />
+          )}
+          {/* 现价 */}
+          <Price
+            className="  text-base font-bold mr-1"
+            amount={String(totalNowPrice)}
+            currencyCode="USD"
+          />
+          {/* 右侧下拉箭头SVG */}
+          <span onClick={() => setCouponModalOpen(true)}>
+            <ArrowDownSvg />
+          </span>
+        </div>
+      </div>
+
+      {/* 底部按钮行:左侧对话图标 + 两个圆角按钮 */}
+      {isAvailable ? (
+        <div className="flex items-center gap-3">
+          {/* 左侧对话图标 */}
+          <ChatSvg />
+          {/* Buy Now 深色绿按钮 */}
+          <button
+            onClick={onBuyNow}
+            type="button"
+            className={clsx(btnClass, "flex-1 bg-ly-deepgreen", {
+              "opacity-25": isLoading,
+            })}
+          >
+            Buy Now
+          </button>
+          {/* Add To Cart 亮绿色按钮 */}
+          <button
+            onClick={onAddToCart}
+            type="button"
+            className={clsx(btnClass, "flex-1 bg-ly-middlegreen", {
+              "opacity-25": isLoading,
+            })}
+          >
+            Add to Cart
+          </button>
         </div>
-    );
-}
+      ) : (
+        <p className="text-center text-red-500 py-2">
+          The selected options are not available!
+        </p>
+      )}
+      <PriceModal
+        visible={couponModalOpen}
+        onClose={() => setCouponModalOpen(false)}
+        priceInfo={priceInfo}
+      />
+    </div>
+  );
+}

+ 20 - 6
src/app/(public)/product/_components/ProductInformation.tsx

@@ -8,6 +8,7 @@ import { useCustomToast } from "@utils/hooks/useToast";
 import { useAddProduct } from "@utils/hooks/useAddToCart";
 import { redirect, RedirectType } from "next/navigation";
 import InstallmentPopup from "./popup/InstallmentPopup";
+import Guide from "./popup/Guide";
 import ProductText from "./ProductText";
 import NewUserBanner from "./NewUserBanner";
 import PromotionDiscountCardProps from "./PromotionDiscountCardProps ";
@@ -45,6 +46,7 @@ export function ProductInformation({
 
   const [productQty, setProductQty] = useState(1);
   const [modalOpen, setModalOpen] = useState(false);
+  const [guideModalOpen, setGuideModalOpen] = useState(false);
   // 获取所有可用变体(quantity > 0)
   const availableVariants = useMemo(() => {
     return getAvailableVariants(flexibleVariants);
@@ -176,9 +178,9 @@ export function ProductInformation({
 
     return {
       variant,
-      totalLinePrice: totalLinePrice,
-      totalNowPrice: totalNowPrice,
-      save: save,
+      totalLinePrice: 170 || totalLinePrice,
+      totalNowPrice: 119 || totalNowPrice,
+      save: 51 || save,
       displayDayPrice: displayDayPrice,
     };
   }, [selected, flexibleVariants, productQty]);
@@ -317,11 +319,18 @@ export function ProductInformation({
         {/* 产品Code */}
         <PromotionDiscountCardProps productId="product-1001" />
       </div>
-
-      <div className="box-border w-full pr-4 pl-4 mt-4">
+      <Guide open={guideModalOpen} onClose={() => setGuideModalOpen(false)} />
+      <div className="box-border w-full pr-4 pl-4 mt-4 relative">
+        <span
+          onClick={() => setGuideModalOpen(true)}
+          className="absolute text-sm font-medium leading-ly-22 underline text-black top-0 right-4"
+        >
+          Guide*
+        </span>
         {productOptions.map((option) => (
           <div key={option.id}>
-            <div className="text-ly-12 font-medium">{option.label}</div>
+            <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;
@@ -414,6 +423,11 @@ export function ProductInformation({
         isLoading={isCartLoading}
         onAddToCart={addToCartHandler}
         onBuyNow={buyNowHandler}
+        priceInfo={{
+          totalNowPrice: currentVariantInfo.totalNowPrice,
+          totalLinePrice: currentVariantInfo.totalLinePrice,
+          save: currentVariantInfo.save,
+        }}
       />
     </>
   );

+ 42 - 28
src/app/(public)/product/_components/ProductReviewSection.tsx

@@ -1,37 +1,51 @@
-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 { getProductReviews } from "@utils/hooks/getProductReviews";
 import type { ProductReviewList } from "@/types/products/productDetail";
+import React, { memo } from "react";
 // 组件入参TS规范
 interface ProductReviewSectionProps {
   productId: string;
   reviews: ProductReviewList;
+  modalOpen: boolean;
+  closeModal: () => void;
+  onOpenBaseModal: () => void;
+  activeTab: string;
+  onChangeTab: (tabKey: string) => void;
 }
 
-export async function ProductReviewSection({
-    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} />
-                {reviews.length > 0 ? (
-                  <>
-                    <ReviewDetail
-                      reviewDetails={reviews}
-                      totalReview={reviews.length}
-                      productId={productId}
-                    />
-                  </>
-                ) : ( <NoReview />)}
-
-            </div>
-            
-        </>
-
-    );
-}
+function ProductReviewSection({
+  productId,
+  reviews,
+  modalOpen,
+  closeModal,
+  activeTab,
+  onOpenBaseModal,
+  onChangeTab,
+}: ProductReviewSectionProps) {
+  // const getAllreviews = await getProductReviews(productId)
+  console.log("productId-------------:", productId, reviews);
+  return (
+    <>
+      <div className="box-border w-full">
+        {reviews.length > 0 ? (
+          <>
+            <ReviewDetail
+              reviewDetails={reviews}
+              totalReview={reviews.length}
+              productId={productId}
+              modalOpen={modalOpen}
+              onOpenBaseModal={onOpenBaseModal}
+              onClose={closeModal}
+              activeTab={activeTab}
+              onChangeTab={onChangeTab} // 关键:把切换tab的函数传给弹窗
+            />
+          </>
+        ) : (
+          <NoReview />
+        )}
+      </div>
+    </>
+  );
+}
+export default React.memo(ProductReviewSection);

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 148 - 0
src/app/(public)/product/_components/ShopServicePanel.tsx


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 123 - 19
src/app/(public)/product/_components/SwitchButton.tsx


+ 81 - 0
src/app/(public)/product/_components/popup/Guide.tsx

@@ -0,0 +1,81 @@
+"use client";
+import Image from "next/image";
+import { useEffect } from "react";
+
+interface GuidePopup {
+  open: boolean;
+  onClose: () => void;
+}
+
+export default function Guide({ open, onClose }: GuidePopup) {
+  // 弹窗打开时锁定滚动
+  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 pb-4">
+        {/* 右上角关闭按钮 */}
+        <div className="flex items-center justify-between px-3 py-3 border-b border-neutral-200">
+          <h2 className="text-ly-16 leading-ly-24 font-medium text-black">
+            Guide
+          </h2>
+          {/* 关闭按钮 X */}
+          <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>
+        </div>
+        {/* 弹窗介绍图,替换为你的实际图片地址 */}
+        <div className="h-106 overflow-y-auto mt-3 px-3">
+        <Image
+          src="/image/products_details/guide.png"
+          alt="Buy Now Pay Later Info"
+          width={960}
+          height={1307}
+          className="w-full h-auto block"
+        />
+        </div>
+      </div>
+    </div>
+  );
+}

+ 36 - 28
src/app/(public)/product/_components/popup/InstallmentPopup.tsx

@@ -30,43 +30,51 @@ export default function InstallmentPopup({ open, onClose }: InstallmentPopup) {
       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">
+      <div className="relative w-full max-w-sm bg-white rounded-xl overflow-hidden pb-4">
         {/* 右上角关闭按钮 */}
-        <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"
+        <div className="flex items-center justify-between px-3 py-3 border-b border-neutral-200">
+          <h2 className="text-ly-16 leading-ly-24 font-medium text-black">
+            Installment Description
+          </h2>
+          {/* 关闭按钮 X */}
+          <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"
           >
-            <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>
+            <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>
+        </div>
         {/* 弹窗介绍图,替换为你的实际图片地址 */}
+        <div className="h-106 overflow-y-auto mt-3 px-3">
         <Image
-          src="/images/bnpl-modal-info.png"
+          src="/image/products_details/installment_description.png"
           alt="Buy Now Pay Later Info"
           width={960}
           height={1307}
           className="w-full h-auto block"
         />
+        </div>
       </div>
     </div>
   );

+ 102 - 0
src/app/(public)/product/_components/popup/PriceModal.tsx

@@ -0,0 +1,102 @@
+"use client";
+import clsx from "clsx";
+import { Price } from "@components/theme/ui/Price";
+
+// 价格数据类型,复用之前商品价格结构
+export type PriceInfo = {
+  totalLinePrice: number; // 原价 Retail price
+  totalNowPrice: number;  // 折后价 Deal Price
+  save: number;           // 优惠 Max Discount,现价=原价时自动为0
+};
+
+interface PriceModalModalProps {
+  visible: boolean;
+  onClose: () => void;
+  priceInfo: PriceInfo;
+}
+
+export default function PriceModal({
+  visible,
+  onClose,
+  priceInfo,
+}: PriceModalModalProps) {
+  if (!visible) return null;
+
+  const { totalLinePrice, totalNowPrice, save } = priceInfo;
+  // 现价=原价时折扣置0
+  const realDiscount = totalLinePrice === totalNowPrice ? 0 : save;
+
+  return (
+    // 全屏灰色遮罩
+    <div
+      className="fixed inset-0 bg-black/50 z-[1000] flex items-end justify-center"
+      onClick={(e) => e.target === e.currentTarget && onClose()}
+    >
+      {/* 弹窗白色容器 */}
+      <div className="bg-white w-full max-w-170 rounded-t-2xl p-4 pt-3 relative">
+        {/* 标题 + 右上角关闭叉号 */}
+        <div className="flex items-center justify-between mb-3">
+          <h2 className="text-base leading-6 font-medium">
+            Use coupon to enjoy the following benefits
+          </h2>
+          <button
+            onClick={onClose}
+            className="text-2xl hover:opacity-60 transition-opacity"
+            aria-label="Close modal"
+          >
+            ×
+          </button>
+        </div>
+
+        {/* 分割线 */}
+        <div className="border-b border-gray-200 mb-3"></div>
+
+        {/* 价格计算区域 白色背景块 */}
+        <div className="bg-gray-50 py-4 px-4 rounded-lg mb-2">
+          <div className="flex items-center justify-around text-center gap-2 flex-wrap">
+            {/* 原价 Retail price */}
+            <div>
+              <Price
+                className="text-ly-20 leading-ly-25 font-normal block "
+                amount={String(totalLinePrice)}
+                currencyCode="USD"
+              />
+              <p className="text-ly-12 leading-ly-20 font-normal">Retail price</p>
+            </div>
+
+            {/* 减号 */}
+            <span className="text-4xl font-bold">-</span>
+
+            {/* 最大优惠 Max Discount */}
+            <div>
+              <Price
+                className="text-ly-20 leading-ly-25 font-normal "
+                amount={String(realDiscount)}
+                currencyCode="USD"
+              />
+              <p className="text-ly-12 leading-ly-20 font-normal">Max Discount</p>
+            </div>
+
+            {/* 等于号 */}
+            <span className="text-4xl font-bold">=</span>
+
+            {/* 最终折后价 Deal Price */}
+            <div>
+              <Price
+                className="text-ly-20 leading-ly-25 font-bold"
+                amount={String(totalNowPrice)}
+                currencyCode="USD"
+              />
+              <p className="text-ly-12 leading-ly-20 font-normal">Deal Price</p>
+            </div>
+          </div>
+        </div>
+
+        {/* 底部提示文字 */}
+        <p className="text-ly-12 leading-ly-20 font-normal">
+          Discounts shown are estimated, final price may vary with the coupon you use.
+        </p>
+      </div>
+    </div>
+  );
+}

+ 173 - 0
src/app/(public)/product/_components/popup/ShoppingSecurityModal.tsx

@@ -0,0 +1,173 @@
+"use client";
+import { useState, useEffect, useRef } from "react";
+
+type TabKey = "shipping" | "return" | "paylater" | "hair";
+interface BaseModalProps {
+  isOpen: boolean;
+  onClose: () => void;
+}
+export default function ShoppingSecurityModal({
+  isOpen,
+  onClose,
+}: BaseModalProps) {
+  const [activeTab, setActiveTab] = useState<TabKey>("shipping");
+  // 存储每个tab按钮DOM ref
+  const tabRefMap = useRef<Map<TabKey, HTMLButtonElement | null>>(new Map());
+
+  // 弹窗打开锁定滚动
+  useEffect(() => {
+    if (isOpen) document.body.style.overflow = "hidden";
+    else document.body.style.overflow = "";
+    return () => {
+      document.body.style.overflow = "";
+    };
+  }, [isOpen]);
+
+  if (!isOpen) return null;
+
+  const tabList = [
+    { key: "shipping" as TabKey, label: "Free Fast Shipping" },
+    { key: "return" as TabKey, label: "30 Days Return" },
+    { key: "paylater" as TabKey, label: "Secure Pay Later" },
+    { key: "hair" as TabKey, label: "100% Human Hair" },
+  ];
+
+  // 点击Tab:切换状态 + 自动滚动到可视区
+  const handleTabClick = (key: TabKey) => {
+    setActiveTab(key);
+    const targetBtn = tabRefMap.current.get(key);
+    if (targetBtn) {
+      targetBtn.scrollIntoView({
+        behavior: "smooth",
+        block: "nearest",
+        inline: "center", // 让选中tab居中显示,也可改为"nearest"贴边
+      });
+    }
+  };
+
+  const tabContent: Record<TabKey, React.ReactNode> = {
+    shipping: (
+      <>
+        <div className="bg-neutral-50 rounded-lg py-3 pl-3 pr-1.5 ">
+          <ul className="mb-3">
+            <li>• Free Worldwide Shipping</li>
+            <li>• Orders Process within 24 Hours</li>
+            <li>• Fast Delivery from US/China Warehouse</li>
+            <li>• Real-Time Tracking Included</li>
+            <li>• Express Options Available in Select Regions</li>
+          </ul>
+        </div>
+        <p className="">
+          Need help? Contact:{" "}
+          <a href="mailto:service@asteriahair.com" className="underline">
+            service@asteriahair.com
+          </a>
+        </p>
+      </>
+    ),
+    return: (
+      <>
+        <div className="bg-neutral-50 rounded-lg py-3 pl-3 pr-1.5 ">
+          <ul className=" mb-3">
+            <li>• Full Refund or Exchange within 30 days</li>
+            <li>• Free Return Shipping Label (US Only)</li>
+            <li>• Fast & Hassle-Free Return Process</li>
+          </ul>
+        </div>
+        <p className="">
+          Note: Items must be unused and returned in original condition
+        </p>
+      </>
+    ),
+    paylater: (
+      <>
+        <div className="bg-neutral-50 rounded-lg py-3 pl-3 pr-1.5">
+          <ul className="mb-3">
+            <li>• Shop Freely without Upfront Full Payment</li>
+            <li>• Buy Now Pay Later: PayPal · Afterpay · Klarna · Apple Pay</li>
+            <li>
+              • Credit/Debit Card: MasterCard · Visa · American Express · JCB
+            </li>
+          </ul>
+        </div>
+        <p className="">
+          Note: You must be 18+ and meet eligibility requirements. Late fees may
+          apply. Estimated payments exclude insurance, which can be selected at
+          checkout.
+        </p>
+      </>
+    ),
+    hair: (
+      <div className="bg-neutral-50 rounded-lg py-3 pl-3 pr-1.5 ">
+        <ul className="">
+          <li>• Guaranteed Premium 100% Virgin Human Hair</li>
+          <li>• Soft · Natural · Long-Lasting · Restyled · Dyed</li>
+          <li>• No Synthetic Fiber Mixed</li>
+          <li>• Raw Hair Collection Available</li>
+          <li>• Third-Party Tested for Quality & Safety</li>
+        </ul>
+      </div>
+    ),
+  };
+
+  return (
+    <div className="fixed inset-0 z-1001 flex items-center justify-center">
+      <div onClick={onClose} className="absolute inset-0 bg-black/50" />
+
+      <div className="absolute bottom-0 w-full max-w-lg bg-white rounded-t-xl overflow-hidden z-10 mx-4">
+        {/* 头部标题关闭 */}
+        <div className="flex items-center justify-between px-4 pt-4 ">
+          <h2 className="text-ly-16 leading-ly-24 font-medium text-black">
+            Sales Services
+          </h2>
+          <button
+            onClick={onClose}
+            className="w-8 h-8 flex items-center justify-center text-black/70 hover:text-black transition-colors"
+          >
+            <svg
+              width="24"
+              height="24"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+            >
+              <path d="M18 6L6 18M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+
+        {/* Tab横向滚动容器 */}
+        <div className="flex px-4 py-4 mt-1  gap-2 overflow-x-auto scrollbar-hide">
+          {tabList.map((tab) => (
+            <button
+              key={tab.key}
+              ref={(el) => {
+                tabRefMap.current.set(tab.key, el);
+                return undefined;
+              }} // 存入ref映射
+              onClick={() => handleTabClick(tab.key)}
+              className={`relative whitespace-nowrap px-2 pb-1.5 text-xs transition-colors ${
+                activeTab === tab.key
+                  ? "text-[#1E7049] font-bold"
+                  : "text-black"
+              }`}
+            >
+              {tab.label}
+              {activeTab === tab.key && (
+                <span className="absolute left-1/2 -translate-x-1/2 bottom-0 w-10 h-0.5 bg-[#1E7049] block"></span>
+              )}
+            </button>
+          ))}
+        </div>
+
+        {/* 内容滚动区域 */}
+        <div className=" px-4 pb-4">
+          <div className=" text-black text-ly-12 leading-ly-24  font-normal">
+            {tabContent[activeTab]}
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 69 - 0
src/app/(public)/product/_components/popup/VipPlusModal.tsx

@@ -0,0 +1,69 @@
+"use client";
+import { BaseModalProps } from "@/types/modal";
+import { useEffect } from "react";
+
+export default function VipPlusModal({ isOpen, onClose }: BaseModalProps) {
+  // 弹窗打开时锁定页面滚动
+  useEffect(() => {
+    if (isOpen) document.body.style.overflow = "hidden";
+    else document.body.style.overflow = "";
+    return () => {
+      document.body.style.overflow = "";
+    };
+  }, [isOpen]);
+
+  if (!isOpen) return null;
+
+  return (
+    // 全局灰色遮罩层
+    <div className="fixed inset-0 z-1001 flex items-center justify-center ">
+      {/* 灰色半透明遮罩,点击空白关闭弹窗 */}
+      <div
+        onClick={onClose}
+        className="absolute inset-0 bg-black/50"
+      />
+
+      {/* 弹窗主体容器 */}
+      <div className="absolute bottom-0 w-full max-w-lg bg-white rounded-t-xl overflow-hidden z-10 mx-4">
+        {/* 弹窗头部:标题 + 关闭按钮 */}
+        <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-200">
+          <h2 className="text-ly-16 leading-ly-24 font-medium text-black">VIP Plus Exclusive</h2>
+          {/* 关闭按钮 X */}
+          <button
+            onClick={onClose}
+            className="w-8 h-8 flex items-center justify-center text-black "
+          >
+            <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
+              <path d="M18 6L6 18M6 6l12 12" />
+            </svg>
+          </button>
+        </div>
+        {/* 内容区域:固定高度、纵向滚动 */}
+        <div className=" p-4">
+          {/* 浅绿色背景内容块 */}
+          <div className="bg-[#F0FAF7] h-[296px] overflow-y-auto rounded-sm pt-3 px-3 text-ly-13 leading-ly-22 font-normal">
+            <p className="">Unlock extra savings every time you shop — stack more discounts, save more instantly.</p>
+
+            <h3 className="font-semibold text-black">Membership Price</h3>
+            <p className="">$29.99 → <span className="font-semibold text-black">$14.99 (Save $15 today)</span></p>
+
+            <h3 className="font-semibold ">What You Get</h3>
+            <ul className="">
+              <li>• Extra 5% OFF on every order (stackable with all promotions)</li>
+              <li>• $20 Gift Cards included (2 × $10, long-term valid)</li>
+              <li>• Valid for 120 days from purchase date</li>
+              <li>• Reference Support:</li>
+              <li>• Priority access to exclusive offers & events</li>
+              <li>• Faster support & member-first service</li>
+            </ul>
+
+            <h3 className="text-xl font-semibold text-black">Why Join VIP Plus</h3>
+            <p className="">Your membership instantly pays for itself — You receive $20 in gift cards + ongoing 5% savings, making VIP Plus effectively free to join.</p>
+             <h3 className="text-xl font-semibold text-black">How it Works</h3>
+            <p className="">Activate VIP Plus directly in your Cart or Checkout to unlock all benefits immediately.</p>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 70 - 0
src/app/(public)/product/_components/question/AskQuestionModal.tsx

@@ -0,0 +1,70 @@
+import { useState } from "react";
+
+interface AskQuestionModalProps {
+  visible: boolean;
+  onClose: () => void;
+  onSubmit: (text: string) => void;
+}
+
+export default function AskQuestionModal({
+  visible,
+  onClose,
+  onSubmit,
+}: AskQuestionModalProps) {
+  // 输入框内容
+  const [inputText, setInputText] = useState("");
+  const maxLength = 300;
+
+  // 提交处理
+  const handleSubmit = () => {
+    if (!inputText.trim()) return;
+    onSubmit(inputText.trim());
+    setInputText(""); // 清空输入
+  };
+
+  if (!visible) return null;
+
+  return (
+    // 灰色遮罩层
+    <div className="fixed inset-0 bg-black/60 flex items-end justify-center z-50">
+      {/* 弹窗白色容器 */}
+      <div className="bg-white w-full max-w-[520px] rounded-t-xl p-4 relative">
+        {/* 标题 + 关闭叉号 */}
+        <div className="flex justify-between items-center mb-6">
+          <h3 className="text-xl font-bold text-center flex-1">
+            Ask Questions
+          </h3>
+          <button
+            onClick={onClose}
+            className="absolute top-6 right-6 text-2xl hover:opacity-60"
+          >
+            ×
+          </button>
+        </div>
+
+        {/* 输入框区域 */}
+        <div className="relative mb-4">
+          <textarea
+            value={inputText}
+            onChange={(e) => setInputText(e.target.value.slice(0, maxLength))}
+            placeholder="Enter your question?"
+            className="w-full min-h-40 border border-orange-200 rounded-lg p-4 outline-none focus:border-orange-400 resize-none"
+          />
+          {/* 字数统计右下角 */}
+          <span className="absolute bottom-3 right-3 text-gray-400 text-sm">
+            {inputText.length}/{maxLength}
+          </span>
+        </div>
+
+        {/* 底部黑色提交按钮 */}
+        <button
+          onClick={handleSubmit}
+          disabled={!inputText.trim()}
+          className="w-full bg-black text-white py-2 rounded-xl text-lg font-medium disabled:opacity-50"
+        >
+          Ask Questions
+        </button>
+      </div>
+    </div>
+  );
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 153 - 0
src/app/(public)/product/_components/question/ComponentsQuestionModal.tsx


+ 144 - 0
src/app/(public)/product/_components/question/FaqList.tsx

@@ -0,0 +1,144 @@
+"use client";
+import { useState } from "react";
+import QAModal from "./QAModal"
+// 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;
+}) {
+  return (
+    <div className="border border-gray-100 rounded-lg bg-white p-4 mb-3">
+      {/* 标题行:Q图标 + 问题 + 上下箭头 */}
+      <button
+        type="button"
+        onClick={onToggle}
+        className="w-full flex items-start justify-between text-left gap-3"
+      >
+        {/* 绿色圆形Q图标 SVG */}
+        <div className="w-8 h-8 rounded-full bg-emerald-700 flex items-center justify-center shrink-0">
+          Q
+        </div>
+
+        {/* 问题文字 */}
+        <h3 className="text-base font-medium text-emerald-800 flex-1">
+          {item.question}
+        </h3>
+
+        {/* 上下箭头 SVG 切换 */}
+        <div className="shrink-0 mt-1">
+          {isOpen ? (
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="18"
+              height="18"
+              fill="#222"
+              viewBox="0 0 16 16"
+            >
+              <path
+                fillRule="evenodd"
+                d="M7.646 4.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1-.708.708L8 5.707l-5.646 5.647a.5.5 0 0 1-.708-.708l6-6Z"
+              />
+            </svg>
+          ) : (
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="18"
+              height="18"
+              fill="#222"
+              viewBox="0 0 16 16"
+            >
+              <path
+                fillRule="evenodd"
+                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>
+          )}
+        </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-700 border border-black 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>
+            {/* 点赞SVG图标 */}
+            <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>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+
+// 外层FAQ列表容器
+export default function FaqList({ faqList }: { faqList: FaqItem[] }) {
+  // 记录当前展开的FAQ id,仅支持单条展开
+  const [openId, setOpenId] = useState<string | null>(null);
+ const [isQaOpen, setIsQaOpen] = useState(false);
+
+  // 默认只展示前3条,点击Show More展示全部
+  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">
+      {/* FAQ列表 */}
+      <div className="space-y-1 mt-4">
+        {displayList.map((item) => (
+          <FaqAccordionItem
+            key={item.id}
+            item={item}
+            isOpen={openId === item.id}
+            onToggle={() => toggleFaq(item.id)}
+          />
+        ))}
+      </div>
+
+      {/* 底部Show More按钮 */}
+      <button
+        type="button"
+        onClick={() => setIsQaOpen(true)}
+        className="mt-6 block mx-auto px-8 py-2 rounded-full border border-gray-400 text-gray-800 hover:bg-gray-50 transition-colors"
+      >
+        Show More
+      </button>
+      {/* 弹窗子组件,传入控制参数 */}
+      <QAModal open={isQaOpen} onClose={() => setIsQaOpen(false)} />
+    </div>
+  );
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 390 - 0
src/app/(public)/product/_components/question/QAModal.tsx


+ 98 - 58
src/app/(public)/product/_components/review/AddProductReview.tsx

@@ -11,6 +11,10 @@ import Image from "next/image";
 import { CreateProductReviewInput } from "@/types/review";
 import { AddUploadImage } from "@components/common/icons/AddUploadImage";
 
+interface UploadImgItem {
+  fileName: string;
+  preview: string;
+}
 export default function AddProductReview({
   productId,
   onClose,
@@ -18,8 +22,10 @@ export default function AddProductReview({
   productId: string;
   onClose: () => void;
 }) {
-  const [imageFile, setImageFile] = useState<string | null>(null);
-  const [imagePreview, setImagePreview] = useState<string | null>(null);
+  // const [imageFile, setImageFile] = useState<UploadImgItem[]>([]);
+  const [imgList, setImgList] = useState<UploadImgItem[]>([]);
+  const MAX_UPLOAD = 3;
+  // const [imagePreview, setImagePreview] = useState<string | null>(null);
   const [errors, setErrors] = useState({
     title: "",
     comment: "",
@@ -36,17 +42,46 @@ export default function AddProductReview({
 
   const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
     const file = e.target.files?.[0];
-    if (file) {
-      setImageFile(file.name);
-      setReviewInfo((prev) => ({ ...prev, attachments: file.name }));
-      const reader = new FileReader();
-      reader.onloadend = () => {
-        setImagePreview(reader.result as string);
-      };
-      reader.readAsDataURL(file);
+    if (!file) return;
+
+    // 判断是否达到3张上限
+    if (imgList.length >= MAX_UPLOAD) {
+      showToast(`You can only upload up to ${MAX_UPLOAD} images`, "warning");
+      e.target.value = ""; // 清空input,防止重复选
+      return;
     }
-  };
 
+    const reader = new FileReader();
+    reader.onloadend = () => {
+      const newItem: UploadImgItem = {
+        fileName: file.name,
+        preview: reader.result as string,
+      };
+      // 追加到图片数组
+      const newList = [...imgList, newItem];
+      setImgList(newList);
+      // 拼接所有文件名给表单附件
+      setReviewInfo((prev) => ({
+        ...prev,
+        attachments: newList.map((item) => item.fileName).join(","),
+      }));
+    };
+    reader.readAsDataURL(file);
+    // 清空input,允许重复选择同一张图片
+    e.target.value = "";
+  };
+  // 根据下标删除指定图片
+  const removeSingleImage = (targetIndex: number) => {
+    const newList = imgList.filter((_, idx) => idx !== targetIndex);
+    setImgList(newList);
+    // 重新拼接附件字符串
+    setReviewInfo((prev) => ({
+      ...prev,
+      attachments: newList.length
+        ? newList.map((item) => item.fileName).join(",")
+        : null,
+    }));
+  };
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
 
@@ -84,7 +119,8 @@ export default function AddProductReview({
         rating: reviewInfo.rating,
         name: "Guest User",
         email: "guest@mail.com",
-        attachments: "",
+        // 读取表单里拼接好的多图文件名,无图则为空字符串
+        attachments: reviewInfo.attachments ?? "",
       };
 
       await createProductReview(input);
@@ -95,8 +131,7 @@ export default function AddProductReview({
         rating: 0,
         attachments: null,
       });
-      setImageFile(null);
-      setImagePreview(null);
+      setImgList([]); // 新增:清空多张图片数组
       setErrors({
         title: "",
         comment: "",
@@ -139,58 +174,61 @@ export default function AddProductReview({
       </div>
       <div className="flex flex-col gap-4">
         <div className="space-y-4">
-          {imagePreview ? (
-            <div className="border-2 w-[120px] h-auto max-h-[120px] border-dashed border-gray-300  rounded-xl text-center transition-colors hover:border-primary-500">
-              <div className="space-y-3">
-                <div className="relative mx-auto">
-                  <Image
-                    src={imagePreview}
-                    alt="Preview"
-                    width={120}
-                    height={120}
-                    className="w-full h-full object-cover rounded-lg"
-                  />
-                  <button
-                    type="button"
-                    onClick={() => {
-                      setImageFile(null);
-                      setImagePreview(null);
-                    }}
-                    className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
+          {/* 多张图片预览 横向排列 */}
+          <div className="flex flex-wrap gap-3 items-start">
+            {imgList.map((imgItem, idx) => (
+              <div
+                key={idx}
+                className="relative w-[120px] h-[120px] border-2 border-dashed border-gray-300 rounded-xl"
+              >
+                <Image
+                  src={imgItem.preview}
+                  alt="preview"
+                  width={120}
+                  height={120}
+                  className="w-full h-full object-cover rounded-lg"
+                />
+                {/* 单张删除按钮 */}
+                <button
+                  type="button"
+                  onClick={() => removeSingleImage(idx)}
+                  className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
+                >
+                  <svg
+                    xmlns="http://www.w3.org/2000/svg"
+                    className="h-4 w-4"
+                    fill="none"
+                    viewBox="0 0 24 24"
+                    stroke="currentColor"
                   >
-                    <svg
-                      xmlns="http://www.w3.org/2000/svg"
-                      className="h-4 w-4"
-                      fill="none"
-                      viewBox="0 0 24 24"
-                      stroke="currentColor"
-                    >
-                      <path
-                        strokeLinecap="round"
-                        strokeLinejoin="round"
-                        strokeWidth={2}
-                        d="M6 18L18 6M6 6l12 12"
-                      />
-                    </svg>
-                  </button>
-                </div>
+                    <path
+                      strokeLinecap="round"
+                      strokeLinejoin="round"
+                      strokeWidth={2}
+                      d="M6 18L18 6M6 6l12 12"
+                    />
+                  </svg>
+                </button>
+                {/* 图片文件名 */}
+                <p className="text-xs text-gray-500 truncate px-1 mt-1">
+                  {imgItem.fileName}
+                </p>
               </div>
-            </div>
-          ) : (
-            <div className="border-2 border-dashed border-gray-300 w-32 h-32 rounded-xl p-6 text-center transition-colors hover:border-primary-500">
-              <div>
+            ))}
+
+            {/* 上传按钮:不足3张才显示 */}
+            {imgList.length < MAX_UPLOAD && (
+              <div className="border-2 border-dashed border-gray-300 w-32 h-32 rounded-xl p-6 text-center transition-colors hover:border-primary-500">
                 <label
                   htmlFor="file-upload"
-                  className="mx-auto flex cursor-pointer flex-col items-center justify-center gap-2"
+                  className="mx-auto flex cursor-pointer flex-col items-center justify-center gap-2 h-full"
                 >
                   <div className="mx-auto flex h-8 w-8 items-center justify-center rounded-full">
                     <AddUploadImage />
                   </div>
-
                   <div className="text-sm text-center">
                     <span className="font-medium">Add Image</span>
                   </div>
-
                   <input
                     id="file-upload"
                     name="file-upload"
@@ -201,9 +239,11 @@ export default function AddProductReview({
                   />
                 </label>
               </div>
-            </div>
-          )}
-          {imageFile && <p className="text-sm">{imageFile}</p>}
+            )}
+          </div>
+          {/* <p className="text-sm text-gray-400">
+            Max {MAX_UPLOAD} images allowed
+          </p> */}
         </div>
 
         <div className="flex flex-col gap-4">

+ 247 - 0
src/app/(public)/product/_components/review/AddProductReviewForm.tsx

@@ -0,0 +1,247 @@
+"use client";
+import { useState } from "react";
+import { Input, Textarea } from "@heroui/input";
+import { AddRatingStar } from "./AddRatingStar";
+import { Button } from "@components/common/button/Button";
+import ReviewImageUploader from "./ReviewImageUploader";
+import { useCustomToast } from "@utils/hooks/useToast";
+import { useProductReview } from "@utils/hooks/useProductReview";
+import type {
+  UploadImgItem,
+  ReviewFormData,
+  ReviewFormErrors,
+} from "@/types/products/review";
+
+const MAX_UPLOAD = 3;
+
+interface AddProductReviewFormProps {
+  productId: string;
+  onCloseForm: () => void; // 关闭表单回调(Cancel/提交成功都调用)
+}
+
+export default function AddProductReviewForm({
+  productId,
+  onCloseForm,
+}: AddProductReviewFormProps) {
+  const { showToast } = useCustomToast();
+  const { createProductReview, isLoading } = useProductReview();
+
+  // 图片状态
+  const [imgList, setImgList] = useState<UploadImgItem[]>([]);
+
+  // 完整表单数据(匹配设计稿字段)
+  const [formData, setFormData] = useState<ReviewFormData>({
+    name: "",
+    hairLength: "",
+    qualityRating: 0,
+    shippingRating: 0,
+    serviceRating: 0,
+    comment: "",
+    attachments: null,
+  });
+
+  // 表单错误校验
+  const [errors, setErrors] = useState<ReviewFormErrors>({
+    name: "",
+    hairLength: "",
+    qualityRating: "",
+    comment: "",
+  });
+
+  // 更新表单通用方法
+  const updateForm = (
+    key: keyof ReviewFormData,
+    value: string | number | null,
+  ) => {
+    setFormData((prev) => ({ ...prev, [key]: value }));
+    // 输入时清空对应报错
+    if (key in errors) setErrors((prev) => ({ ...prev, [key]: "" }));
+  };
+
+  // 表单提交
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    const newErrors: ReviewFormErrors = {
+      name: "",
+      hairLength: "",
+      qualityRating: "",
+      comment: "",
+    };
+
+    // 必填校验
+    if (!formData.name.trim()) newErrors.name = "Name is required";
+    if (!formData.hairLength.trim())
+      newErrors.hairLength = "Hair Length is required";
+    if (formData.qualityRating === 0)
+      newErrors.qualityRating = "Please rate quality";
+    if (!formData.comment.trim()) newErrors.comment = "Comment is required";
+
+    setErrors(newErrors);
+    const hasError = Object.values(newErrors).some((v) => v);
+    if (hasError) {
+      showToast("Please fill all required fields marked with *", "danger");
+      return;
+    }
+
+    try {
+      // 组装接口入参
+      const input = {
+        productId: Number(productId.split("/").pop()),
+        name: formData.name,
+        hairLength: formData.hairLength,
+        quality: formData.qualityRating,
+        shipping: formData.shippingRating,
+        service: formData.serviceRating,
+        comment: formData.comment,
+        attachments: formData.attachments ?? "",
+      };
+      await createProductReview(input);
+      showToast("Review submitted successfully!", "success");
+      // 重置表单 + 关闭
+      setFormData({
+        name: "",
+        hairLength: "",
+        qualityRating: 0,
+        shippingRating: 0,
+        serviceRating: 0,
+        comment: "",
+        attachments: null,
+      });
+      setImgList([]);
+      onCloseForm();
+    } catch (err) {
+      console.error(err);
+      showToast("Failed to submit review, try again", "danger");
+    }
+  };
+
+  // 字符计数
+  const commentMax = 300;
+  const commentCount = formData.comment.length;
+
+  return (
+    <form onSubmit={handleSubmit} className="w-full  bg-white mt-6">
+      {/* Name */}
+      <div className="mb-4">
+        <label className="text-ly-14 leading-ly-24 font-normal mb-1">
+          * Name
+        </label>
+        <Input
+          placeholder="Enter Your Name"
+          value={formData.name}
+          onChange={(e) => updateForm("name", e.target.value)}
+          variant="bordered"
+          isInvalid={!!errors.name}
+          errorMessage={errors.name}
+          classNames={{
+            input: "h-14 text-base px-4",
+            inputWrapper:
+              "border border-[#E5E5E5FF] rounded-none h-14 border-gray-400 px-0",
+          }}
+        />
+      </div>
+
+      {/* Hair Length */}
+      <div className="mb-6">
+        <label className="text-ly-14 leading-ly-24 font-normal mb-1">
+          * Hair Length
+        </label>
+        <Input
+          placeholder="Hair Length"
+          value={formData.hairLength}
+          onChange={(e) => updateForm("hairLength", e.target.value)}
+          variant="bordered"
+          isInvalid={!!errors.hairLength}
+          errorMessage={errors.hairLength}
+          classNames={{
+            input: "h-14 text-base px-4",
+            inputWrapper:
+              "border border-[#E5E5E5FF] rounded-none h-14 border-gray-400 px-0",
+          }}
+        />
+      </div>
+
+      {/* 三项星级评分 */}
+      <div className="space-y-3 mb-6">
+        <div className="flex items-center gap-3">
+          <span className="w-18 font-medium">Quality:</span>
+          <AddRatingStar
+            value={formData.qualityRating}
+            onChange={(v) => updateForm("qualityRating", v)}
+          />
+          <span>{formData.qualityRating}.0</span>
+        </div>
+        {errors.qualityRating && (
+          <p className="text-red-500 text-sm">{errors.qualityRating}</p>
+        )}
+
+        <div className="flex items-center gap-3">
+          <span className="w-18 font-medium">Shipping:</span>
+          <AddRatingStar
+            value={formData.shippingRating}
+            onChange={(v) => updateForm("shippingRating", v)}
+          />
+          <span>{formData.shippingRating}.0</span>
+        </div>
+
+        <div className="flex items-center gap-3">
+          <span className="w-18 font-medium">Service:</span>
+          <AddRatingStar
+            value={formData.serviceRating}
+            onChange={(v) => updateForm("serviceRating", v)}
+          />
+          <span>{formData.serviceRating}.0</span>
+        </div>
+      </div>
+
+      {/* 评论输入框 */}
+      <div className="mb-4">
+        <label className="block font-medium mb-2">Write a review</label>
+        <Textarea
+          placeholder="Write Your Comments Here..."
+          value={formData.comment}
+          onChange={(e) =>
+            updateForm("comment", e.target.value.slice(0, commentMax))
+          }
+          minRows={6}
+          variant="bordered"
+          isInvalid={!!errors.comment}
+          errorMessage={errors.comment}
+          classNames={{
+            input: "",
+            inputWrapper:
+              "border border-[#E5E5E5FF] rounded-none  border-gray-400 ",
+          }}
+        />
+        <p className="text-right text-sm text-gray-400 -mt-6 pr-3">
+          {commentCount}/{commentMax}
+        </p>
+      </div>
+
+      {/* 图片上传组件 */}
+      <ReviewImageUploader
+        imgList={imgList}
+        setImgList={setImgList}
+        setAttachments={(val) => updateForm("attachments", val)}
+        MAX_UPLOAD={MAX_UPLOAD}
+      />
+
+      {/* 底部按钮 */}
+      <div className="flex gap-3 mt-6">
+        <button
+          type="button"
+          onClick={onCloseForm}
+          className="relative  font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer w-full   rounded-full bg-[#E1E8E6FF] p-4 tracking-wide "
+        >
+          Cancel Review
+        </button>
+        <Button
+          title={isLoading ? "Submitting..." : "Submit Review"}
+          type="submit"
+          disabled={isLoading}
+          className="relative  font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer   rounded-full bg-[#E1E8E6FF] p-4 tracking-wide  "
+        />
+      </div>
+    </form>
+  );
+}

+ 53 - 0
src/app/(public)/product/_components/review/HairPhotoGallery.tsx

@@ -0,0 +1,53 @@
+"use client";
+import Image from "next/image";
+
+interface HairPhotoGalleryProps {
+  onOpenModal: () => void;
+  imageList: string[];
+}
+
+export default function HairPhotoGallery({
+  onOpenModal,
+  imageList,
+}: HairPhotoGalleryProps) {
+  const displayImages = imageList.slice(0, 4);
+  return (
+    <div className="w-full flex gap-2 overflow-hidden my-4">
+      {displayImages.map((src, idx) => (
+        <div
+          key={idx}
+          onClick={onOpenModal}
+          className="relative flex-1 aspect-3/4 cursor-pointer rounded overflow-hidden"
+        >
+          <Image
+            src={src}
+            alt={`hair ${idx}`}
+            width={87}
+            height={116}
+            className="w-full h-full object-cover"
+          />
+          {idx == 3 ? (
+            <div className="absolute inset-0 bg-black/25 flex flex-col items-center justify-center text-white">
+              <p className="text-sm font-normal leading-ly-22 underline">More</p>
+              <p className="text-sm font-normal leading-ly-22 underline">Photos</p>
+            </div>
+          ) : (
+            ""
+          )}
+        </div>
+      ))}
+
+      {/* More Photos 第四张卡片 */}
+      {/* <div
+        onClick={onOpenModal}
+        className="relative flex-1 aspect-4/3 cursor-pointer rounded overflow-hidden"
+      >
+        <Image
+          src="/img/hair4.jpg"
+          alt="more photos"
+          className="object-cover"
+        />
+      </div> */}
+    </div>
+  );
+}

+ 103 - 0
src/app/(public)/product/_components/review/QuestionInputModal.tsx

@@ -0,0 +1,103 @@
+"use client";
+import { useState } from "react";
+import { Textarea } from "@heroui/input";
+import { Button } from "@components/common/button/Button";
+
+interface QuestionInputModalProps {
+  productId: string;
+  onCloseForm: () => void; // 关闭表单回调(Cancel/提交成功都调用)
+}
+
+export default function QuestionInputModal({
+  productId,
+  onCloseForm,
+}: QuestionInputModalProps) {
+  const MAX_LENGTH = 300;
+  const [content, setContent] = useState("");
+  const [loading, setLoading] = useState(false);
+  const [errorMsg, setErrorMsg] = useState("");
+
+  // 提交处理
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    setErrorMsg("");
+    const trimText = content.trim();
+    console.log('cacacacac:------------------------');
+    
+    // 简单校验
+    if (!trimText) {
+      setErrorMsg("Please write your question");
+      return;
+    }
+
+    try {
+      setLoading(true);
+    //   await onSubmit(trimText);
+      // 提交成功清空+关闭
+      setContent("");
+      onCloseForm();
+    } catch (err) {
+      setErrorMsg("Submit failed, please try again");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 关闭重置表单
+  const handleCancel = () => {
+    setContent("");
+    setErrorMsg("");
+    onCloseForm();
+  };
+
+  const currentCount = content.length;
+  return (
+    <form onSubmit={handleSubmit} className="w-full pt-6 flex flex-col gap-1">
+      <h2 className=" font-normal text-ly-16 leading-ly-24 text-black">Ask a question</h2>
+
+      {/* 文本输入框 匹配设计稿样式 */}
+      <div className="relative">
+        <Textarea
+          placeholder="Write Your Comments Here..."
+          value={content}
+          onChange={(e) => {
+            // 限制最大300字符
+            const val = e.target.value.slice(0, MAX_LENGTH);
+            setContent(val);
+            if (errorMsg) setErrorMsg("");
+          }}
+          minRows={8}
+          variant="bordered"
+          isInvalid={!!errorMsg}
+          errorMessage={errorMsg}
+          classNames={{
+            base: "w-full",
+            inputWrapper: "border rounded-none border-[#E5E5E5FF] ",
+            input: "text-base  min-h-[200px]",
+          }}
+        />
+        {/* 右下角字符计数 */}
+        <span className="absolute right-3 bottom-3 text-sm text-gray-400">
+          {currentCount}/{MAX_LENGTH}
+        </span>
+      </div>
+
+      {/* 底部两个按钮 */}
+      <div className="flex gap-3 mt-2">
+        <button
+          type="button"
+          onClick={handleCancel}
+          className="relative  font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer w-full  rounded-full bg-[#E1E8E6FF] p-4 tracking-wide"
+        >
+          Cancel
+        </button>
+        <Button
+          title={loading ? "Submitting..." : "Submit Question"}
+          type="submit"
+          disabled={loading}
+          className="relative  font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer    rounded-full bg-[#E1E8E6FF] p-4 tracking-wide"
+        />
+      </div>
+    </form>
+  );
+}

+ 40 - 16
src/app/(public)/product/_components/review/ReviewAdd.tsx

@@ -4,20 +4,49 @@ import { useState } from "react";
 import { Modal, ModalContent } from "@heroui/modal";
 import AddProductReview from "./AddProductReview";
 import { ReviewButton } from "@components/common/button/ReviewButton";
+import AddProductReviewForm from "./AddProductReviewForm";
+import QuestionInputModal from "./QuestionInputModal";
 
-
-export default function ReviewAdd({
-  productId,
-}: {productId: string;}) {
+export default function ReviewAdd({ productId }: { productId: string }) {
   const [open, setOpen] = useState(false);
-
+  const [qusopen, setQueopen] = useState(false);
+  const handleCloseForm = () => setOpen(false);
+  const setShowForm = () => {
+    setOpen((prev) => true);
+    setQueopen((prev) => false);
+  };
   return (
     <>
-      <div>
-          <ReviewButton setShowForm={setOpen} />
+      <div className="flex items-center justify-start gap-3">
+        <ReviewButton setShowForm={setShowForm} />
+        <div
+          onClick={() => {
+            setOpen((prev) => false);
+            setQueopen((prev) => true);
+          }}
+          className="relative flex font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer h-fit items-center justify-center rounded-full bg-[#E1E8E6FF] p-4 tracking-wide mt-4"
+        >
+          Ask a question
+        </div>
       </div>
-
-      <Modal
+      {/* 页面内表单区块,非弹窗 */}
+      {open && (
+        <AddProductReviewForm
+          productId={productId}
+          // 传关闭回调函数,表单内部Cancel/提交成功都会调用
+          onCloseForm={handleCloseForm}
+        />
+      )}
+      {qusopen && (
+        <QuestionInputModal
+          productId={productId}
+          // 传关闭回调函数,表单内部Cancel/提交成功都会调用
+          onCloseForm={() => {
+            setQueopen(false);
+          }}
+        />
+      )}
+      {/* <Modal
         isOpen={open}
         onOpenChange={setOpen}
         backdrop="blur"
@@ -33,15 +62,10 @@ export default function ReviewAdd({
       >
         <ModalContent className="p-0 border-none">
           {(onClose) => (
-            <AddProductReview
-              productId={productId}
-              onClose={onClose}
-            />
+            <AddProductReview productId={productId} onClose={onClose} />
           )}
         </ModalContent>
-      </Modal>
+      </Modal> */}
     </>
   );
 }
-
-

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 130 - 43
src/app/(public)/product/_components/review/ReviewDetail.tsx


+ 97 - 0
src/app/(public)/product/_components/review/ReviewImageGalleryModal.tsx

@@ -0,0 +1,97 @@
+"use client";
+import { useRef, useState } from "react";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Navigation } from "swiper/modules";
+import Image from "next/image";
+import { GalleryItem } from "@/types/products/review";
+import "swiper/css";
+import "swiper/css/navigation";
+
+interface GalleryModalProps {
+  open: boolean;
+  onClose: () => void;
+  galleryList: GalleryItem[];
+  initialSlide: number;
+}
+
+export default function ReviewImageGalleryModal({
+  open,
+  onClose,
+  galleryList,
+  initialSlide,
+}: GalleryModalProps) {
+  const swiperRef = useRef<any>(null);
+  // 新增状态存储当前下标,初始值传入initialSlide
+  const [currentIndex, setCurrentIndex] = useState(initialSlide);
+
+  if (!open) return null;
+
+  return (
+    <div className="fixed inset-0 bg-black z-[100] flex flex-col">
+      {/* 顶部关闭按钮 */}
+      <div className="flex justify-between items-center p-4 text-white shrink-0">
+        <span className="text-lg">
+          {currentIndex + 1} / {galleryList.length}
+        </span>
+        <button onClick={onClose}>
+          <svg width="28" height="28" fill="none" stroke="#fff" strokeWidth="2">
+            <path d="M6 6L18 18M18 6L6 18" />
+          </svg>
+        </button>
+      </div>
+
+      {/* 轮播大图 */}
+      <div className="flex-1 flex items-center justify-center">
+        <Swiper
+          ref={swiperRef}
+          initialSlide={initialSlide}
+          modules={[Navigation]}
+          navigation
+          className="w-full h-full"
+          // 滑动切换时更新下标
+          onSlideChange={(swiper) => setCurrentIndex(swiper.activeIndex)}
+        >
+          {galleryList.map((item, idx) => (
+            <SwiperSlide
+              key={idx}
+              className="flex flex-col justify-between h-full py-4"
+            >
+              {/* 大图 */}
+              <div className="flex-1 flex items-center justify-center px-2">
+                <Image
+                  src={item.imageUrl}
+                  alt={`gallery-${idx}`}
+                  width={600}
+                  height={800}
+                  className="max-w-full max-h-[70vh] object-contain"
+                />
+              </div>
+              {/* 底部用户评论信息 */}
+              <div className="bg-black/80 text-white p-4 mt-4">
+                <div className="flex items-center gap-2 mb-2">
+                  <div className="w-10 h-10 rounded-full bg-gray-300"></div>
+                  <span className="font-medium">{item.review.name}</span>
+                  <span className="ml-auto">{item.review.likeCount} 👍</span>
+                </div>
+                <div className="flex gap-0.5 mb-2">
+                  {Array.from({ length: 5 }).map((_, i) => (
+                    <svg
+                      key={i}
+                      width="14"
+                      height="14"
+                      fill={i < item.review.rating ? "#fff" : "#666"}
+                      viewBox="0 0 24 24"
+                    >
+                      <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
+                    </svg>
+                  ))}
+                </div>
+                <p className="text-sm text-gray-200">{item.review.comment}</p>
+              </div>
+            </SwiperSlide>
+          ))}
+        </Swiper>
+      </div>
+    </div>
+  );
+}

+ 104 - 0
src/app/(public)/product/_components/review/ReviewImageUploader.tsx

@@ -0,0 +1,104 @@
+"use client";
+import { useState } from "react";
+import Image from "next/image";
+import { AddUploadImage } from "@components/common/icons/AddUploadImage";
+import { useCustomToast } from "@utils/hooks/useToast";
+import type { UploadImgItem } from "@/types/products/review";
+
+interface ReviewImageUploaderProps {
+  imgList: UploadImgItem[];
+  setImgList: (list: UploadImgItem[]) => void;
+  setAttachments: (val: string | null) => void;
+  MAX_UPLOAD: number;
+}
+
+export default function ReviewImageUploader({
+  imgList,
+  setImgList,
+  setAttachments,
+  MAX_UPLOAD,
+}: ReviewImageUploaderProps) {
+  const { showToast } = useCustomToast();
+
+  // 上传图片
+  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    if (imgList.length >= MAX_UPLOAD) {
+      showToast(`You can only upload up to ${MAX_UPLOAD} images`, "warning");
+      e.target.value = "";
+      return;
+    }
+
+    const reader = new FileReader();
+    reader.onloadend = () => {
+      const newItem: UploadImgItem = {
+        fileName: file.name,
+        preview: reader.result as string,
+      };
+      const newList = [...imgList, newItem];
+      setImgList(newList);
+      setAttachments(newList.map((item) => item.fileName).join(","));
+    };
+    reader.readAsDataURL(file);
+    e.target.value = "";
+  };
+
+  // 删除单张图片
+  const removeSingleImage = (targetIndex: number) => {
+    const newList = imgList.filter((_, idx) => idx !== targetIndex);
+    setImgList(newList);
+    setAttachments(newList.length ? newList.map((item) => item.fileName).join(",") : null);
+  };
+
+  return (
+    <div className="space-y-3 mt-4">
+      <div className="flex flex-wrap gap-2 items-start">
+        {/* 已上传图片预览 */}
+        {imgList.map((imgItem, idx) => (
+          <div
+            key={idx}
+            className="relative w-[114px] h-[152px] border-2 border-dashed border-gray-300 "
+          >
+            <Image
+              src={imgItem.preview}
+              alt="preview"
+              width={120}
+              height={120}
+              className="w-full h-full object-cover"
+            />
+            <button
+              type="button"
+              onClick={() => removeSingleImage(idx)}
+              className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
+            >
+              <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
+              </svg>
+            </button>
+            {/* <p className="text-xs text-gray-500 truncate px-1 mt-1">{imgItem.fileName}</p> */}
+          </div>
+        ))}
+
+        {/* 上传框 不足3张显示 */}
+        {imgList.length < MAX_UPLOAD && (
+          <div className="border-2 border-dashed border-gray-300 w-[114px] h-[152px]  flex flex-col items-center justify-center gap-2">
+            <label htmlFor="file-upload" className="cursor-pointer flex flex-col items-center justify-center h-full w-full">
+              <AddUploadImage />
+              <span className="text-sm text-gray-500">Optional</span>
+              <input
+                id="file-upload"
+                type="file"
+                className="sr-only"
+                accept="image/*"
+                onChange={handleImageUpload}
+              />
+            </label>
+          </div>
+        )}
+      </div>
+      <p className="text-sm text-gray-400">Max {MAX_UPLOAD} images allowed</p>
+    </div>
+  );
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 440 - 0
src/app/(public)/product/_components/review/ReviewModal.tsx


+ 277 - 0
src/app/(public)/product/_components/youmaylike/Recommend.tsx

@@ -0,0 +1,277 @@
+"use client";
+import React from "react";
+import { Swiper, SwiperSlide } from "swiper/react";
+import { Navigation, Pagination } from "swiper/modules";
+import { OpenAddToCartModalButton } from "@/components/common/AddToCartModal/OpenAddToCartModalButton";
+// 导入Swiper基础样式
+import "swiper/css";
+import "swiper/css/navigation";
+
+// 模拟产品数据(12个产品,分2块,每块6个)
+const productData = [
+  // 第一块(6个)
+  {
+    id: 1,
+    img: "https://picsum.photos/400/500?random=1",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 2,
+    img: "https://picsum.photos/400/500?random=2",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 3,
+    img: "https://picsum.photos/400/500?random=3",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 4,
+    img: "https://picsum.photos/400/500?random=4",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 5,
+    img: "https://picsum.photos/400/500?random=5",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 6,
+    img: "https://picsum.photos/400/500?random=6",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+
+  // 第二块(6个)
+  {
+    id: 7,
+    img: "https://picsum.photos/400/500?random=7",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 8,
+    img: "https://picsum.photos/400/500?random=8",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 9,
+    img: "https://picsum.photos/400/500?random=9",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 10,
+    img: "https://picsum.photos/400/500?random=10",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 11,
+    img: "https://picsum.photos/400/500?random=11",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+  {
+    id: 12,
+    img: "https://picsum.photos/400/500?random=12",
+    title: "14-36 In Water Wave Huma...",
+    rating: 4.5,
+    reviewCount: 342,
+    price: 143.35,
+    originalPrice: 143.35,
+  },
+];
+
+// 拆分数据为2个块(每块6个)
+const productBlocks = [
+  productData.slice(0, 6), // 第一块
+  productData.slice(6, 12), // 第二块
+];
+
+const Recommend = () => {
+  return (
+    <div className="w-full max-w-[1200px] mx-auto px-4 py-8">
+      {/* 标题 */}
+      <h2 className="text-2xl font-bold mb-6">You May Also Like </h2>
+
+      {/* Swiper容器 */}
+      <Swiper
+        slidesPerView={1}
+        spaceBetween={20}
+        modules={[Navigation, Pagination]}
+        pagination={{
+          el: ".custom-pagination",
+          clickable: true,
+          renderBullet: (index, className) => {
+            // 所有分页默认底色为 #E5E5E5
+            return `<span class="${className} w-6 h-[5px] rounded-full mx-1 bg-[#E5E5E5]"></span>`;
+          },
+          // 激活时强制覆盖底色,加 !important 提高权重
+          bulletActiveClass: "!bg-[#333]",
+        }}
+        className="relative"
+      >
+        {/* 渲染2个滑动块 */}
+        {productBlocks.map((block, blockIndex) => (
+          <SwiperSlide key={blockIndex} className="!px-0">
+            {/* 每个块:6个产品,2列布局 */}
+            <div className="grid grid-cols-2 gap-4">
+              {block.map((product) => (
+                <div key={product.id} className="flex flex-col gap-2">
+                  {/* 产品图片 */}
+                  <img
+                    src={product.img}
+                    alt={product.title}
+                    className="w-full h-auto object-cover rounded-sm"
+                  />
+                  {/* 产品标题 */}
+                  <p className="text-sm text-gray-800 truncate">
+                    {product.title}
+                  </p>
+                  {/* 评分 + 评论数 */}
+                  <div className="flex items-center gap-1">
+                    <div className="flex">
+                      {/* 星级图标(简化版,可替换为真实星星图标) */}
+                      {[1, 2, 3, 4, 5].map((star) => (
+                        <svg
+                          key={star}
+                          className="w-4 h-4 text-yellow-400"
+                          fill="currentColor"
+                          viewBox="0 0 20 20"
+                        >
+                          <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
+                        </svg>
+                      ))}
+                    </div>
+                    <span className="text-xs text-gray-600">
+                      {product.reviewCount}
+                    </span>
+                  </div>
+                  {/* 价格 + 购物车图标 */}
+                  <div className="flex items-center justify-between">
+                    <div className="flex items-center gap-1">
+                      <span className="font-semibold">${product.price}</span>
+                      <span className="text-xs text-gray-500 line-through">
+                        ${product.originalPrice}
+                      </span>
+                    </div>
+                    {/* 购物车图标 */}
+                    <OpenAddToCartModalButton
+                      productUrlKey={
+                        "http://nshop.test/hd-transparent-deep-wave-&-curly-lace-frontal-closure-wig"
+                      }
+                      icon={
+                        <svg
+                          xmlns="http://www.w3.org/2000/svg"
+                          width="32"
+                          height="32"
+                          viewBox="0 0 32 32"
+                          fill="none"
+                        >
+                          <rect
+                            x="0"
+                            y="0"
+                            width="32"
+                            height="32"
+                            rx="8"
+                            fill="#036141"
+                          ></rect>
+                          <path
+                            stroke="rgba(255, 255, 255, 1)"
+                            stroke-width="1.3333333333333333"
+                            stroke-linejoin="round"
+                            stroke-linecap="round"
+                            d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
+                          ></path>
+                          <ellipse
+                            cx="11.066040515899658"
+                            cy="23.199928283691406"
+                            rx="1.4000248908996582"
+                            ry="1.4000015258789062"
+                            stroke="rgba(255, 255, 255, 1)"
+                            stroke-width="1.3333333333333333"
+                            stroke-linejoin="round"
+                            stroke-linecap="round"
+                          ></ellipse>
+                          <ellipse
+                            cx="23.1998291015625"
+                            cy="23.199928283691406"
+                            rx="1.4000244140625"
+                            ry="1.4000015258789062"
+                            stroke="rgba(255, 255, 255, 1)"
+                            stroke-width="1.3333333333333333"
+                            stroke-linejoin="round"
+                            stroke-linecap="round"
+                          ></ellipse>
+                          <path
+                            stroke="rgba(255, 255, 255, 1)"
+                            stroke-width="1.3333333333333333"
+                            stroke-linejoin="round"
+                            stroke-linecap="round"
+                            d="M15.2656 15.2665L18.999 15.2665"
+                          ></path>
+                          <path
+                            stroke="rgba(255, 255, 255, 1)"
+                            stroke-width="1.3333333333333333"
+                            stroke-linejoin="round"
+                            stroke-linecap="round"
+                            d="M17.1348 17.1332L17.1348 13.3999"
+                          ></path>
+                        </svg>
+                      }
+                    />
+                  </div>
+                </div>
+              ))}
+            </div>
+          </SwiperSlide>
+        ))}
+
+        {/* 自定义分页器容器(底部小按钮) */}
+        <div className="custom-pagination flex justify-center mt-4"></div>
+      </Swiper>
+    </div>
+  );
+};
+
+export default Recommend;

+ 5 - 4
src/components/common/button/ReviewButton.tsx

@@ -2,21 +2,22 @@ import { IS_GUEST } from "@utils/constants";
 import { getCookie } from "@utils/cookie-tools";
 import { useRouter } from "next/navigation";
 
-export const ReviewButton = ({ setShowForm, className }: { setShowForm: (show: boolean) => void, className?: string }) => {
+export const ReviewButton = ({ setShowForm, className }: { setShowForm: () => void, className?: string }) => {
     const IsGuest = getCookie(IS_GUEST);
     const router = useRouter();
     const handleAddReview = () => {
         if (IsGuest === "true" || IsGuest === null) {
-            router.push("/customer/login");
+              setShowForm();
+            // router.push("/customer/login");
         } else {
-            setShowForm(true);
+            setShowForm();
         }
     };
 
     return (
         <button
             onClick={handleAddReview}
-            className={`relative flex w-full min-w-[18rem] max-w-[20rem] cursor-pointer h-fit items-center justify-center rounded-full bg-blue-600 p-4 tracking-wide text-white mt-6 ${className}`}
+            className={`relative flex font-normal text-ly-14 leading-ly-24 text-[#1E3932FF] py-1 px-3.5  cursor-pointer h-fit items-center justify-center rounded-full bg-[#E1E8E6FF] p-4 tracking-wide mt-4 ${className}`}
         > 
             Write a review
         </button>

+ 50 - 0
src/types/products/review.ts

@@ -0,0 +1,50 @@
+// 单条评论节点
+export interface ProductReviewNode {
+  name: string;
+  title: string;
+  rating: number;
+  comment: string;
+  createdAt: string;
+  attachments: string[]; // 评论图片数组,空=无图
+  isVip: boolean;
+  tag: "influencer" | "verified" | "";
+  likeCount: number;
+}
+
+export interface ProductReviewEdge {
+  node: ProductReviewNode;
+  __typename: "ProductReviewEdge";
+}
+export type ProductReviewList = ProductReviewEdge[];
+
+// 图库弹窗一维平铺图片项
+export interface GalleryItem {
+  reviewIndex: number;
+  imageUrl: string;
+  review: ProductReviewNode;
+}
+
+// Tab类型
+export type ReviewTabType = string; //"all" | "newest" | "photos"
+export interface UploadImgItem {
+  fileName: string;
+  preview: string;
+}
+
+// 新增完整表单数据类型
+export interface ReviewFormData {
+  name: string;
+  hairLength: string;
+  qualityRating: number;
+  shippingRating: number;
+  serviceRating: number;
+  comment: string;
+  attachments: string | null;
+}
+
+export interface ReviewFormErrors {
+  name: string;
+  hairLength: string;
+  qualityRating: string;
+  comment: string;
+}