瀏覽代碼

详情页评论

zhangzf 6 天之前
父節點
當前提交
1d154a8b3a

+ 3 - 2
src/app/(public)/_components/HomeBestSellers.tsx

@@ -6,6 +6,7 @@ import { OpenAddToCartModalButton } from "@/components/common/AddToCartModal/Ope
 import "swiper/css";
 import "swiper/css/navigation";
 import { HomeProduct } from "@/types/home/type";
+import Link from "next/link";
 interface HomeBestSellersProps {
   bestSellers: HomeProduct[];
 }
@@ -53,9 +54,9 @@ const HomeBestSellers = ({ bestSellers }: HomeBestSellersProps) => {
                     className="w-full h-auto object-cover rounded-sm"
                   />
                   {/* 产品标题 */}
-                  <p className="text-sm text-gray-800 truncate">
+                  <Link href={"/aaaa"||""} className="text-sm text-gray-800 truncate">
                     {product.name}
-                  </p>
+                  </Link>
                   {/* 评分 + 评论数 */}
                   <div className="flex items-center gap-1">
                     <div className="flex">

+ 13 - 5
src/app/(public)/product/[...urlProduct]/page.tsx

@@ -3,6 +3,7 @@ import { Suspense } from "react";
 // import clsx from "clsx";
 import type { ProductReviewList } from "@/types/products/productDetail";
 import { getProductReviews } from "@utils/hooks/getProductReviews";
+import { restApiFetch } from "@utils/bagisto/index";
 import {
   ProductDetailSkeleton,
   RelatedProductSkeleton,
@@ -64,9 +65,16 @@ 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 allReviews: ProductReviewList = await getProductReviews(
+  //   String(product._id),
+  // );
+  // 3792
+  const res = await restApiFetch({
+  api: `/shop/products/${3792}/reviews?page=1&per_page=10&has_images=0&sort=all`,
+  method: "GET",
+});
+const allReviews = res?.body?.data;
+console.log("res----------------------",allReviews);
   const questionTotal = 73;
   // const imageUrl = getImageUrl(product?.baseImageUrl, baseUrl, NOT_IMAGE);
 
@@ -130,7 +138,7 @@ export default async function ProductPage({
           />
 
           <SwitchButton
-            productId={String(product._id)}
+            productId={"3792" || String(product._id)}
             reviewList={allReviews}
             reviewTotal={allReviews.length}
             questionTotal={questionTotal}
@@ -141,7 +149,7 @@ export default async function ProductPage({
         </Suspense>
         <Recommend/>
       </div>
-
+        
       <Suspense fallback={<RelatedProductSkeleton />}>
         <RelatedProductsSection fullPath={fullPath} />
       </Suspense>

+ 20 - 7
src/app/(public)/product/_components/review/AddProductReviewForm.tsx

@@ -11,7 +11,7 @@ import type {
   ReviewFormData,
   ReviewFormErrors,
 } from "@/types/products/review";
-
+import { clientFetch } from "@/lib/restApiClient";
 const MAX_UPLOAD = 3;
 
 interface AddProductReviewFormProps {
@@ -37,7 +37,7 @@ export default function AddProductReviewForm({
     shippingRating: 0,
     serviceRating: 0,
     comment: "",
-    attachments: null,
+    attachments: [],
   });
 
   // 表单错误校验
@@ -51,7 +51,7 @@ export default function AddProductReviewForm({
   // 更新表单通用方法
   const updateForm = (
     key: keyof ReviewFormData,
-    value: string | number | null,
+    value: string | number | null | string[],
   ) => {
     setFormData((prev) => ({ ...prev, [key]: value }));
     // 输入时清空对应报错
@@ -86,16 +86,29 @@ export default function AddProductReviewForm({
     try {
       // 组装接口入参
       const input = {
-        productId: Number(productId.split("/").pop()),
+        product_id: Number(productId.split("/").pop()),
         name: formData.name,
+        title:formData.hairLength,
         hairLength: formData.hairLength,
+        rating:formData.qualityRating,
         quality: formData.qualityRating,
         shipping: formData.shippingRating,
         service: formData.serviceRating,
         comment: formData.comment,
-        attachments: formData.attachments ?? "",
+        images: formData.attachments ?? "",
       };
-      await createProductReview(input);
+      console.log('input----------------------------------------',input);
+      const res = await clientFetch("/api/shop/reviews", {
+            method: "POST",
+            headers: {
+              "Content-Type": "application/json",
+            },
+            // 传给接口的参数,对应 params = await req.json()
+            body: JSON.stringify(input),
+          });
+      // await createProductReview(input);
+      console.log("评论提交res-------------------------------------------------",res);
+      
       showToast("Review submitted successfully!", "success");
       // 重置表单 + 关闭
       setFormData({
@@ -105,7 +118,7 @@ export default function AddProductReviewForm({
         shippingRating: 0,
         serviceRating: 0,
         comment: "",
-        attachments: null,
+        attachments: [],
       });
       setImgList([]);
       onCloseForm();

+ 19 - 17
src/app/(public)/product/_components/review/ReviewDetail.tsx

@@ -39,27 +39,28 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
   onOpenBaseModal,
 }) => {
   const [visibleCount, setVisibleCount] = useState(5);
+// reviews 处理的是原来的接口逻辑
+  const reviews: any[] =
+    reviewDetails?.map((edge) => edge.node) || []; 
+  // const { reviewAvg, ratingCounts } = getReviews(reviews);
 
-  const reviews: ProductReviewNode[] =
-    reviewDetails?.map((edge) => edge.node) || [];
-
-  const { reviewAvg, ratingCounts } = getReviews(reviews);
-
-  const visibleReviews = reviews.slice(0, visibleCount);
+  const visibleReviews = reviewDetails.slice(0, visibleCount);
+  console.log("visibleReviews-------------------:",visibleReviews);
+  
   return (
     <>
       <div className="flex flex-col flex-wrap gap-x-5 sm:gap-x-10">
         <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
+            {/* <Rating
               length={5}
               size="size-5"
               star={reviewAvg}
               reviewCount={totalReview}
-            />
+            /> */}
           </div>
           <span style={{ display: "none" }}>{productId}</span>
-          <div className="flex w-full max-w-[280px] overflow-hidden rounded-sm">
+          {/* <div className="flex w-full max-w-[280px] overflow-hidden rounded-sm">
             {Object.entries(ratingCounts)
               .reverse()
               .filter(([_, count]) => (count as number) > 0)
@@ -96,7 +97,7 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
                   </Tooltip>
                 </div>
               ))}
-          </div>
+          </div> */}
         </div>
 
         <div className="flex w-full flex-1 flex-col gap-5 py-2 sm:pt-6">
@@ -113,11 +114,12 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
                     name,
                     title,
                     comment,
-                    createdAt,
+                    created_at,
                     rating,
-                    images,
+                    attachments,
                     customer,
-                  }: ProductReviewNode,
+                    migrated_from_asteria_id,
+                  }: any,
                   index: number,
                 ) => (
                   <div
@@ -192,7 +194,7 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
 
                       {/* 右上角点赞数字+爱心图标 */}
                       <div className="flex items-center gap-1.5 text-black-400 shrink-0">
-                        <span className="text-sm">1234</span>
+                        <span className="text-sm">{migrated_from_asteria_id}</span>
 
                         <svg
                           xmlns="http://www.w3.org/2000/svg"
@@ -217,16 +219,16 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
                     </div>
 
                     {/* 评价图片横向列表 */}
-                    {isArray(images) && images && images.length > 0 && (
+                    {isArray(attachments) && attachments && attachments.length > 0 && (
                       <div className="mt-4 pl-[56px] flex gap-3 flex-wrap">
-                        {images.map((img) => (
+                        {attachments.map((img:any) => (
                           <div
                             key={img.reviewId}
                             className="w-[160px] h-[160px] rounded-lg overflow-hidden shrink-0 border border-[#E5E5E5FF]"
                           >
                             <GridTileImage
                               fill
-                              alt={`${img.reviewId}-review`}
+                              alt={`${img.type}-review`}
                               className="object-cover"
                               src={img.url}
                             />

+ 26 - 7
src/app/(public)/product/_components/review/ReviewImageUploader.tsx

@@ -33,13 +33,17 @@ export default function ReviewImageUploader({
 
     const reader = new FileReader();
     reader.onloadend = () => {
+      const base64Str = reader.result as string;
+
       const newItem: UploadImgItem = {
         fileName: file.name,
-        preview: reader.result as string,
+        preview: base64Str,
+        base64: base64Str,
       };
       const newList = [...imgList, newItem];
       setImgList(newList);
-      setAttachments(newList.map((item) => item.fileName).join(","));
+      // attachments 直接赋值base64数组,不做逗号拼接
+      setAttachments(newList.map((item) => item.base64));
     };
     reader.readAsDataURL(file);
     e.target.value = "";
@@ -49,7 +53,8 @@ export default function ReviewImageUploader({
   const removeSingleImage = (targetIndex: number) => {
     const newList = imgList.filter((_, idx) => idx !== targetIndex);
     setImgList(newList);
-    setAttachments(newList.length ? newList.map((item) => item.fileName).join(",") : null);
+    // 删除后同步更新base64数组
+    setAttachments(newList.map((item) => item.base64));
   };
 
   return (
@@ -73,8 +78,19 @@ export default function ReviewImageUploader({
               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
+                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> */}
@@ -84,7 +100,10 @@ export default function ReviewImageUploader({
         {/* 上传框 不足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">
+            <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
@@ -101,4 +120,4 @@ export default function ReviewImageUploader({
       <p className="text-sm text-gray-400">Max {MAX_UPLOAD} images allowed</p>
     </div>
   );
-}
+}

+ 87 - 78
src/app/(public)/product/_components/review/ReviewModal.tsx

@@ -7,7 +7,7 @@ import {
 } from "@/types/products/review";
 import ReviewImageGalleryModal from "./ReviewImageGalleryModal";
 import Image from "next/image";
-
+import { clientFetch } from "@/lib/restApiClient";
 // 分页常量
 const PAGE_SIZE = 10;
 
@@ -32,74 +32,83 @@ async function fetchReviewApi(
   //     total: 100,
   //   };
   // 全局固定100条模拟评论总数据
-  const mockTotal = 100;
-  const fullMockData: ProductReviewList = [];
-
-  for (let i = 1; i <= mockTotal; i++) {
-    // 30条带图片评论,70条无图
-    const hasImage = i <= 30;
-    // 带图评论随机1~3张图片
-    const imgCount = hasImage ? Math.floor(Math.random() * 3) + 1 : 0;
-    const attachments: string[] = [];
-    for (let j = 1; j <= imgCount; j++) {
-      attachments.push(
-        `https://cdn.alipearlhair.com/media/reviewimages/cache/6/image/150x/040ec09b1e35df139433887a97daa66f/reviewimages/7O_A6W92AW3D_87N5PR8CO.png`,
-      );
+  // 2. 服务端 fetch 请求我们自己的 Next.js API
+    let has_images =0;
+    if(tab =='photos'){
+      has_images =1 ;
     }
+  const res = await clientFetch(`/api/shop/products/${pid}/reviews?page=${pageNum}&per_page=${PAGE_SIZE}&has_images=${has_images}&sort=${tab}`);
+  console.log("弹窗内res-=---------------------------------aa:",res?.data?.data);
+  
+  // const mockTotal = 100;
+  // const fullMockData: ProductReviewList = [];
 
-    // 随机星级1-5
-    const randomRating = Math.floor(Math.random() * 5) + 1;
-    // 随机标签
-    const tagArr: ("influencer" | "verified" | "")[] = [
-      "",
-      "verified",
-      "influencer",
-    ];
-    const randomTag = tagArr[Math.floor(Math.random() * tagArr.length)];
-    // 随机时间,用于Newest排序
-    const randomDayOffset = Math.floor(Math.random() * 90);
-    const createDate = new Date();
-    createDate.setDate(createDate.getDate() - randomDayOffset);
+  // for (let i = 1; i <= mockTotal; i++) {
+  //   // 30条带图片评论,70条无图
+  //   const hasImage = i <= 30;
+  //   // 带图评论随机1~3张图片
+  //   const imgCount = hasImage ? Math.floor(Math.random() * 3) + 1 : 0;
+  //   const attachments: string[] = [];
+  //   for (let j = 1; j <= imgCount; j++) {
+  //     attachments.push(
+  //       `https://cdn.alipearlhair.com/media/reviewimages/cache/6/image/150x/040ec09b1e35df139433887a97daa66f/reviewimages/7O_A6W92AW3D_87N5PR8CO.png`,
+  //     );
+  //   }
 
-    fullMockData.push({
-      __typename: "ProductReviewEdge",
-      node: {
-        name: `D****${i}`,
-        title: `${16 + Math.floor(Math.random() * 3)}`,
-        rating: randomRating,
-        comment:
-          "This lady is so sweetly gorgeous. I love the curl pattern, the side part makes it worth every penny spent. Hair is soft and natural, I bleached it the day she arrived.",
-        createdAt: createDate.toISOString(),
-        attachments,
-        isVip: true,
-        tag: randomTag,
-        likeCount: 1000 + Math.floor(Math.random() * 500),
-      },
-    });
-  }
+  //   // 随机星级1-5
+  //   const randomRating = Math.floor(Math.random() * 5) + 1;
+  //   // 随机标签
+  //   const tagArr: ("influencer" | "verified" | "")[] = [
+  //     "",
+  //     "verified",
+  //     "influencer",
+  //   ];
+  //   const randomTag = tagArr[Math.floor(Math.random() * tagArr.length)];
+  //   // 随机时间,用于Newest排序
+  //   const randomDayOffset = Math.floor(Math.random() * 90);
+  //   const createDate = new Date();
+  //   createDate.setDate(createDate.getDate() - randomDayOffset);
+
+  //   fullMockData.push({
+  //     __typename: "ProductReviewEdge",
+  //     node: {
+  //       name: `D****${i}`,
+  //       title: `${16 + Math.floor(Math.random() * 3)}`,
+  //       rating: randomRating,
+  //       comment:
+  //         "This lady is so sweetly gorgeous. I love the curl pattern, the side part makes it worth every penny spent. Hair is soft and natural, I bleached it the day she arrived.",
+  //       createdAt: createDate.toISOString(),
+  //       attachments,
+  //       isVip: true,
+  //       tag: randomTag,
+  //       likeCount: 1000 + Math.floor(Math.random() * 500),
+  //     },
+  //   });
+  // }
   // 根据tab筛选数据
-  let filteredList = [...fullMockData];
-  if (tab === "newest") {
-    // 最新:创建时间倒序
-    filteredList.sort(
-      (a, b) =>
-        new Date(b.node.createdAt).getTime() -
-        new Date(a.node.createdAt).getTime(),
-    );
-  } else if (tab === "photos") {
-    // 仅保留带图片评论
-    filteredList = filteredList.filter(
-      (item) => item.node.attachments.length > 0,
-    );
-  }
+  let filteredList = [...res?.data?.data];
+  let totalReview = res?.pagination?.total;
+  // if (tab === "newest") {
+  //   // 最新:创建时间倒序
+  //   filteredList.sort(
+  //     (a, b) =>
+  //       new Date(b.node.createdAt).getTime() -
+  //       new Date(a.node.createdAt).getTime(),
+  //   );
+  // } else if (tab === "photos") {
+  //   // 仅保留带图片评论
+  //   filteredList = filteredList.filter(
+  //     (item) => item.node.attachments.length > 0,
+  //   );
+  // }
 
   // 分页切片
-  const startIndex = (pageNum - 1) * PAGE_SIZE;
-  const pageData = filteredList.slice(startIndex, startIndex + PAGE_SIZE);
+  // const startIndex = (pageNum - 1) * PAGE_SIZE;
+  // const pageData = filteredList.slice(startIndex, startIndex + PAGE_SIZE);
 
   return {
-    list: pageData,
-    total: filteredList.length,
+    list: filteredList,
+    total: totalReview,
   };
 }
 
@@ -166,30 +175,31 @@ const ReviewModal: React.FC<ReviewModalProps> = React.memo(
     // 筛选当前Tab展示的评论
     const filterReviewList = useCallback(() => {
       let list = [...fullReviewList];
-      if (activeTab === "newest") {
-        list.sort(
-          (a, b) =>
-            new Date(b.node.createdAt).getTime() -
-            new Date(a.node.createdAt).getTime(),
-        );
-      } else if (activeTab === "photos") {
-        list = list.filter((item) => item.node.attachments.length > 0);
-      }
-      return list;
+      // if (activeTab === "newest") {
+      //   list.sort(
+      //     (a, b) =>
+      //       new Date(b.node.createdAt).getTime() -
+      //       new Date(a.node.createdAt).getTime(),
+      //   );
+      // } else if (activeTab === "photos") {
+      //   list = list.filter((item) => item.node.attachments.length > 0);
+      // }
+      // console.log("list----------------------------",list);
+      return list; 
     }, [activeTab, fullReviewList]);
 
     // 当前分页渲染数据
-    const displayList = filterReviewList().slice(0, page * PAGE_SIZE);
+    const displayList = filterReviewList();//.slice(0, page * PAGE_SIZE)
 
     // 生成图库一维图片数组
     const buildGalleryList = useCallback((): GalleryItem[] => {
       const allGallery: GalleryItem[] = [];
       filterReviewList().forEach((edge, reviewIdx) => {
-        edge.node.attachments.forEach((imgUrl) => {
+        edge.attachments.forEach((imgUrl) => {
           allGallery.push({
             reviewIndex: reviewIdx,
             imageUrl: imgUrl,
-            review: edge.node,
+            review: edge,
           });
         });
       });
@@ -222,11 +232,10 @@ const ReviewModal: React.FC<ReviewModalProps> = React.memo(
 
       const galleryAll = buildGalleryList();
       const targetReview = filterReviewList()[clickReviewIndex];
-      const targetImg = targetReview.node.attachments[clickImgIndex];
+      const targetImg = targetReview.attachments[clickImgIndex];
       const targetSlide = galleryAll.findIndex(
         (g) => g.reviewIndex === clickReviewIndex && g.imageUrl === targetImg,
       );
-      debugger;
       setGalleryData({ galleryList: galleryAll, initialSlide: targetSlide });
       setGalleryOpen(true);
     };
@@ -291,8 +300,8 @@ const ReviewModal: React.FC<ReviewModalProps> = React.memo(
               ) : displayList.length === 0 ? (
                 <p className="text-center text-gray-500 py-10">No reviews</p>
               ) : (
-                displayList.map((edge, reviewIdx) => {
-                  const item = edge.node;
+                displayList.map((item, reviewIdx) => {
+                  // const item = edge.node;
                   return (
                     <div
                       key={reviewIdx}

+ 48 - 0
src/app/api/shop/products/[productId]/reviews/route.ts

@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { restApiFetch } from "@/utils/bagisto";
+import { isBagistoError } from "@/utils/type-guards";
+import { getAuthToken } from "@/utils/helper";
+type Params = Promise<{ productId: string }>;
+
+export async function GET(req: NextRequest, { params }: { params: Params }) {
+  try {
+    const { productId } = await params;
+    const searchParams = req.nextUrl.searchParams;
+    const guestToken = getAuthToken(req);
+    const response = await restApiFetch<any>({
+      api: `/shop/products/${productId}/reviews`,
+      method: "GET",
+      cache: "no-store",
+      variables: {
+        page: searchParams.get("page") ?? 1,
+        per_page: searchParams.get("per_page") ?? 10,
+        has_images:searchParams.get("has_images") ?? 0, 
+        sort:searchParams.get("sort") ?? 'all', 
+      },
+       guestToken,
+    });
+
+    return NextResponse.json({
+      status: response.status,
+      data: response.body,
+    });
+  } catch (error) {
+    if (isBagistoError(error)) {
+      return NextResponse.json(
+        {
+          data: null,
+          error: error.cause ?? error,
+        },
+        { status: 200 }
+      );
+    }
+
+    return NextResponse.json(
+      {
+        message: "Network error",
+        error: error instanceof Error ? error.message : error,
+      },
+      { status: 500 }
+    );
+  }
+}

+ 38 - 0
src/app/api/shop/reviews/route.ts

@@ -0,0 +1,38 @@
+import { NextRequest, NextResponse } from "next/server";
+import { restApiFetch } from "@/utils/bagisto";
+import { getAuthToken } from "@/utils/helper";
+
+export async function POST(req: NextRequest) {
+    try {
+        const authorizationToken = getAuthToken(req); // 获取headers中的Authorization的值
+        const params = await req.json();
+        const response = await restApiFetch<{
+            data: any; // 这个是返回结果的数据类型,暂时写成any,具体看后端反的数据结构再改成确定的类型1
+            variables: {
+                id: number; // 到时候你自己改看是number还是string
+            }
+        }>({
+            api: '/shop/reviews',
+            method:'POST',
+            cache:'no-store',
+            variables: params,
+            guestToken: authorizationToken,
+        });
+        // 打印后端原始返回结构
+        console.log('接口原始response.body =', JSON.stringify(response.body, null, 2));
+        return NextResponse.json(response.body,{
+            status: response.status,
+        });
+
+    } catch (error) {
+        console.log('/gift/add --- ', error); // 调试用
+        return NextResponse.json(
+            {
+                message: error instanceof Error ? error.message : "Network error",
+                success: false,
+                data: []
+            },
+            { status:500 }
+        );
+    }
+}

+ 6 - 2
src/types/products/review.ts

@@ -1,11 +1,14 @@
 // 单条评论节点
 export interface ProductReviewNode {
+  id:number;
   name: string;
   title: string;
   rating: number;
   comment: string;
-  createdAt: string;
+  created_at: string;
+  updated_at: string;
   attachments: string[]; // 评论图片数组,空=无图
+  migrated_from_asteria_id:number;
   isVip: boolean;
   tag: "influencer" | "verified" | "";
   likeCount: number;
@@ -29,6 +32,7 @@ export type ReviewTabType = string; //"all" | "newest" | "photos"
 export interface UploadImgItem {
   fileName: string;
   preview: string;
+  base64: string;
 }
 
 // 新增完整表单数据类型
@@ -39,7 +43,7 @@ export interface ReviewFormData {
   shippingRating: number;
   serviceRating: number;
   comment: string;
-  attachments: string | null;
+  attachments: string[];
 }
 
 export interface ReviewFormErrors {