Bladeren bron

产品详情页优化:接口优化;代码优化;删除无用代码;

fogwind 6 dagen geleden
bovenliggende
commit
a8d9f993f3
67 gewijzigde bestanden met toevoegingen van 1293 en 2330 verwijderingen
  1. BIN
      public/image/payment/afterpay.png
  2. BIN
      public/image/payment/applepay.png
  3. BIN
      public/image/payment/credit-card.png
  4. BIN
      public/image/payment/credit.png
  5. BIN
      public/image/payment/klarna.png
  6. BIN
      public/image/payment/paypal.png
  7. BIN
      public/image/products_details/afterpay.png
  8. BIN
      public/image/products_details/afterpay.webp
  9. BIN
      public/image/products_details/klarna.png
  10. BIN
      public/image/products_details/klarna.webp
  11. BIN
      public/image/products_details/paypal-expressm.png
  12. BIN
      public/image/products_details/paypal.webp
  13. 1 62
      src/app/(public)/_components/HomeBestSellers.tsx
  14. 0 59
      src/app/(public)/_components/HomeNewArrival.tsx
  15. 0 59
      src/app/(public)/_components/MiddleImageSwiper.tsx
  16. 0 59
      src/app/(public)/_components/RecommendedProducts.tsx
  17. 0 59
      src/app/(public)/_components/Trending.tsx
  18. 1 1
      src/app/(public)/category/[slug]/_components/ProductListing.tsx
  19. 1 1
      src/app/(public)/category/[slug]/page.tsx
  20. 113 37
      src/app/(public)/product/[urlkey]/[id]/page.tsx
  21. 69 0
      src/app/(public)/product/_components/InstallmentTips.tsx
  22. 1 1
      src/app/(public)/product/_components/NewUserBanner.tsx
  23. 20 21
      src/app/(public)/product/_components/ProductAddToCart.tsx
  24. 74 0
      src/app/(public)/product/_components/ProductInforWrapper.tsx
  25. 0 442
      src/app/(public)/product/_components/ProductInformation.tsx
  26. 1 1
      src/app/(public)/product/_components/ProductMedia.tsx
  27. 371 0
      src/app/(public)/product/_components/ProductOptionsVariants.tsx
  28. 42 0
      src/app/(public)/product/_components/ProductPrice.tsx
  29. 3 5
      src/app/(public)/product/_components/ProductReviewSection.tsx
  30. 3 3
      src/app/(public)/product/_components/ProductText.tsx
  31. 32 0
      src/app/(public)/product/_components/SkeletonProductOptions.tsx
  32. 3 2
      src/app/(public)/product/_components/SwitchButton.tsx
  33. 7 2
      src/app/(public)/product/_components/review/AddRatingStar.tsx
  34. 7 36
      src/app/(public)/product/_components/review/ReviewDetail.tsx
  35. 8 67
      src/app/(public)/product/_components/review/ReviewModal.tsx
  36. 0 59
      src/app/(public)/product/_components/youmaylike/Recommend.tsx
  37. 1 1
      src/app/(public)/search/page.tsx
  38. 0 59
      src/components/catalog/product/NewProductGridItems.tsx
  39. 6 1
      src/components/catalog/product/ProductsSection.tsx
  40. 2 2
      src/components/catalog/product/RelatedProductsSection.tsx
  41. 0 420
      src/components/catalog/type.ts
  42. 1 1
      src/components/common/AddToCartModal/AddToCartModal.tsx
  43. 98 47
      src/components/common/AddToCartModal/OpenAddToCartModalButton.tsx
  44. 1 1
      src/components/common/AddToCartModal/ProductOptionsInAddToCartModal.tsx
  45. 9 2
      src/components/common/NextImage.tsx
  46. 9 2
      src/components/common/Rating.tsx
  47. 0 34
      src/components/common/icons/ShoppingCartIcon.tsx
  48. 0 20
      src/components/common/type.ts
  49. 0 59
      src/components/customer/accountSwiper.tsx
  50. 0 53
      src/components/home/ProductCarouselTheme.tsx
  51. 1 1
      src/components/theme/filters/NewMobileFilter.tsx
  52. 0 63
      src/graphql/catalog/fragments/ProductDetailed.ts
  53. 1 1
      src/graphql/catalog/fragments/index.ts
  54. 39 0
      src/graphql/catalog/queries/GetProductBaseInfo.ts
  55. 42 9
      src/graphql/catalog/queries/GetProductById.ts
  56. 0 16
      src/graphql/catalog/queries/GetProductByUrlKey.ts
  57. 28 0
      src/graphql/catalog/queries/GetProductOptions.ts
  58. 0 2
      src/graphql/catalog/queries/GetRelatedProducts.ts
  59. 1 1
      src/graphql/catalog/queries/SearchProducts.ts
  60. 2 1
      src/graphql/catalog/queries/index.ts
  61. 3 3
      src/store/slices/addToCartDialogSlice.ts
  62. 90 179
      src/types/category/type.ts
  63. 139 20
      src/types/products/productDetail.ts
  64. 19 8
      src/types/products/review.ts
  65. 0 345
      src/types/types.ts
  66. 1 1
      src/utils/hooks/useSearchProduct.ts
  67. 43 2
      src/utils/variantTools.ts

BIN
public/image/payment/afterpay.png


BIN
public/image/payment/applepay.png


BIN
public/image/payment/credit-card.png


BIN
public/image/payment/credit.png


BIN
public/image/payment/klarna.png


BIN
public/image/payment/paypal.png


BIN
public/image/products_details/afterpay.png


BIN
public/image/products_details/afterpay.webp


BIN
public/image/products_details/klarna.png


BIN
public/image/products_details/klarna.webp


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


BIN
public/image/products_details/paypal.webp


+ 1 - 62
src/app/(public)/_components/HomeBestSellers.tsx

@@ -91,68 +91,7 @@ const HomeBestSellers = ({ bestSellers }: HomeBestSellersProps) => {
                       </span>
                     </div>
                     {/* 购物车图标 */}
-                    <OpenAddToCartModalButton
-                      productId={product.id}
-                      icon={
-                        <svg
-                          xmlns="http://www.w3.org/2000/svg"
-                          width="32"
-                          height="32"
-                          viewBox="0 0 32 32"
-                          fill="none"
-                        >
-                          <rect
-                            x="0"
-                            y="0"
-                            width="32"
-                            height="32"
-                            rx="8"
-                            fill="#036141"
-                          ></rect>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                          ></path>
-                          <ellipse
-                            cx="11.066040515899658"
-                            cy="23.199928283691406"
-                            rx="1.4000248908996582"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <ellipse
-                            cx="23.1998291015625"
-                            cy="23.199928283691406"
-                            rx="1.4000244140625"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M15.2656 15.2665L18.999 15.2665"
-                          ></path>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M17.1348 17.1332L17.1348 13.3999"
-                          ></path>
-                        </svg>
-                      }
-                    />
+                    <OpenAddToCartModalButton productId={product.id}></OpenAddToCartModalButton>
                   </div>
                 </div>
               ))}

+ 0 - 59
src/app/(public)/_components/HomeNewArrival.tsx

@@ -89,65 +89,6 @@ const HomeNewArrival = ({ newArrival }: NewArrivalProps) => {
                     {/* 购物车图标 */}
                     <OpenAddToCartModalButton
                         productId={product.id}
-                      icon={
-                        <svg
-                          xmlns="http://www.w3.org/2000/svg"
-                          width="32"
-                          height="32"
-                          viewBox="0 0 32 32"
-                          fill="none"
-                        >
-                          <rect
-                            x="0"
-                            y="0"
-                            width="32"
-                            height="32"
-                            rx="8"
-                            fill="#036141"
-                          ></rect>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                          ></path>
-                          <ellipse
-                            cx="11.066040515899658"
-                            cy="23.199928283691406"
-                            rx="1.4000248908996582"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <ellipse
-                            cx="23.1998291015625"
-                            cy="23.199928283691406"
-                            rx="1.4000244140625"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M15.2656 15.2665L18.999 15.2665"
-                          ></path>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M17.1348 17.1332L17.1348 13.3999"
-                          ></path>
-                        </svg>
-                      }
                     />
                   </div>
                 </div>

+ 0 - 59
src/app/(public)/_components/MiddleImageSwiper.tsx

@@ -92,65 +92,6 @@ const MiddleImageSwiper = ({ zeroWear,zeroWearImage }: zeroWearProps) => {
                       {/* 购物车图标 */}
                       <OpenAddToCartModalButton
                          productId={item.id}
-                        icon={
-                          <svg
-                            xmlns="http://www.w3.org/2000/svg"
-                            width="32"
-                            height="32"
-                            viewBox="0 0 32 32"
-                            fill="none"
-                          >
-                            <rect
-                              x="0"
-                              y="0"
-                              width="32"
-                              height="32"
-                              rx="8"
-                              fill="#036141"
-                            ></rect>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                            ></path>
-                            <ellipse
-                              cx="11.066040515899658"
-                              cy="23.199928283691406"
-                              rx="1.4000248908996582"
-                              ry="1.4000015258789062"
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                            ></ellipse>
-                            <ellipse
-                              cx="23.1998291015625"
-                              cy="23.199928283691406"
-                              rx="1.4000244140625"
-                              ry="1.4000015258789062"
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                            ></ellipse>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M15.2656 15.2665L18.999 15.2665"
-                            ></path>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M17.1348 17.1332L17.1348 13.3999"
-                            ></path>
-                          </svg>
-                        }
                       />
                     </div>
                 

+ 0 - 59
src/app/(public)/_components/RecommendedProducts.tsx

@@ -189,65 +189,6 @@ export default function RecommendedProducts({ recommended }: recommendedProps) {
                 productId={
                   item.id
                 }
-                icon={
-                  <svg
-                    xmlns="http://www.w3.org/2000/svg"
-                    width="32"
-                    height="32"
-                    viewBox="0 0 32 32"
-                    fill="none"
-                  >
-                    <rect
-                      x="0"
-                      y="0"
-                      width="32"
-                      height="32"
-                      rx="8"
-                      fill="#036141"
-                    ></rect>
-                    <path
-                      stroke="rgba(255, 255, 255, 1)"
-                      stroke-width="1.3333333333333333"
-                      stroke-linejoin="round"
-                      stroke-linecap="round"
-                      d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                    ></path>
-                    <ellipse
-                      cx="11.066040515899658"
-                      cy="23.199928283691406"
-                      rx="1.4000248908996582"
-                      ry="1.4000015258789062"
-                      stroke="rgba(255, 255, 255, 1)"
-                      stroke-width="1.3333333333333333"
-                      stroke-linejoin="round"
-                      stroke-linecap="round"
-                    ></ellipse>
-                    <ellipse
-                      cx="23.1998291015625"
-                      cy="23.199928283691406"
-                      rx="1.4000244140625"
-                      ry="1.4000015258789062"
-                      stroke="rgba(255, 255, 255, 1)"
-                      stroke-width="1.3333333333333333"
-                      stroke-linejoin="round"
-                      stroke-linecap="round"
-                    ></ellipse>
-                    <path
-                      stroke="rgba(255, 255, 255, 1)"
-                      stroke-width="1.3333333333333333"
-                      stroke-linejoin="round"
-                      stroke-linecap="round"
-                      d="M15.2656 15.2665L18.999 15.2665"
-                    ></path>
-                    <path
-                      stroke="rgba(255, 255, 255, 1)"
-                      stroke-width="1.3333333333333333"
-                      stroke-linejoin="round"
-                      stroke-linecap="round"
-                      d="M17.1348 17.1332L17.1348 13.3999"
-                    ></path>
-                  </svg>
-                }
               />
             </div>
           </div>

+ 0 - 59
src/app/(public)/_components/Trending.tsx

@@ -182,65 +182,6 @@ const Trending = ({ trending }: trendingProps) => {
                       {/* 购物车图标 */}
                       <OpenAddToCartModalButton
                          productId={item.id}
-                        icon={
-                          <svg
-                            xmlns="http://www.w3.org/2000/svg"
-                            width="32"
-                            height="32"
-                            viewBox="0 0 32 32"
-                            fill="none"
-                          >
-                            <rect
-                              x="0"
-                              y="0"
-                              width="32"
-                              height="32"
-                              rx="8"
-                              fill="#036141"
-                            ></rect>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                            ></path>
-                            <ellipse
-                              cx="11.066040515899658"
-                              cy="23.199928283691406"
-                              rx="1.4000248908996582"
-                              ry="1.4000015258789062"
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                            ></ellipse>
-                            <ellipse
-                              cx="23.1998291015625"
-                              cy="23.199928283691406"
-                              rx="1.4000244140625"
-                              ry="1.4000015258789062"
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                            ></ellipse>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M15.2656 15.2665L18.999 15.2665"
-                            ></path>
-                            <path
-                              stroke="rgba(255, 255, 255, 1)"
-                              stroke-width="1.3333333333333333"
-                              stroke-linejoin="round"
-                              stroke-linecap="round"
-                              d="M17.1348 17.1332L17.1348 13.3999"
-                            ></path>
-                          </svg>
-                        }
                       />
                     </div>
                   </div>

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

@@ -11,7 +11,7 @@ import { newSortByFields } from "@utils/constants";
 import { isArray } from "@/utils/type-guards";
 import { useRouter, usePathname } from "next/navigation";
 import {  useApolloClient } from "@apollo/client/react";
-import type{ CategoryProductsResult,PageInfo,CategoryProductNode,GetCategoryAttrFiltersResult,} from "@components/catalog/type";
+import type{ CategoryProductsResult,PageInfo,CategoryProductNode,GetCategoryAttrFiltersResult,} from "@/types/category/type";
 import {CATEGORY_PRODUCTS} from "@/graphql";
 import {transformFiltersForApi} from "@utils/helper";
 interface QueryState {

+ 1 - 1
src/app/(public)/category/[slug]/page.tsx

@@ -6,7 +6,7 @@ import ProductListing from "./_components/ProductListing";
 import type{
     GetCategoryAttrFiltersResult,
     CategoryProductsResult,
-} from "@components/catalog/type";
+} from "@/types/category/type";
 import {
     GET_CATEHORY_BY_SLUG,
     GET_CATEGORY_ATTR_FILTERS,

+ 113 - 37
src/app/(public)/product/[urlkey]/[id]/page.tsx

@@ -1,30 +1,76 @@
+import { type Metadata } from "next";
 import { notFound } from "next/navigation";
-import { Suspense } from "react";
+import { Suspense, cache } from "react";
 import {
   RelatedProductSkeleton,
 } from "@/components/common/skeleton/ProductSkeleton";
 import { BASE_SCHEMA_URL, PRODUCT_TYPE } from "@/utils/constants";
-import { GET_PRODUCT_BY_URL_KEY } from "@/graphql";
+import { 
+  GET_PRODUCT_BASE_INFO,
+  GET_PRODUCT_OPTIONS
+} from "@/graphql";
 import {serverGraphqlFetch} from "@utils/bagisto/index";
+import type { GetProductOptionsResponse, GetProductBaseInfoResponse } from "@/types/products/productDetail";
 import type {
   ProductOption,
-  SingleProductResponse,
+  ResolvedVariant,
   ProductMediaType,
-} from "@components/catalog/type";
+} from "@/types/products/productDetail";
 import { RelatedProductsSection } from "@components/catalog/product/RelatedProductsSection";
 import { ProductMedia } from "@/app/(public)/product/_components/ProductMedia";
-import { ProductInformation } from "@/app/(public)/product/_components/ProductInformation";
 import { ProductDetail } from "@/app/(public)/product/_components/ProductDetail";
 import ProductDescription from "../../_components/ProductDescription";
 import SwitchButton from "../../_components/SwitchButton";
 import ShopServicePanel from "../../_components/ShopServicePanel";
 import Recommend from "../../_components/youmaylike/Recommend"
+import ProductText from "../../_components/ProductText";
 import { formatFlexibleVariants } from "@/utils/variantTools";
+import { ProductInforWrapper } from "../../_components/ProductInforWrapper";
 
 // export const dynamic = 'auto'
 // // 'auto' | 'force-dynamic' | 'error' | 'force-static'
 export const dynamic = "force-dynamic";
 
+
+const getProductBaseInfo = cache(async function(urlkey: string) {
+    return await serverGraphqlFetch<GetProductBaseInfoResponse,{urlKey: string;}>({
+
+      query: GET_PRODUCT_BASE_INFO,
+      variables:{ 
+        urlKey: urlkey 
+      },
+      takeAuthorization: false,
+      cache: 'no-cache'
+    });
+});
+
+
+export async function generateMetadata({
+  params,
+}: {
+  params: Promise<{ urlkey: string;  id: string; }>;
+}): Promise<Metadata> {
+    const { urlkey } = await params;
+    const { data: productData } = await getProductBaseInfo(urlkey);
+
+    const product = productData?.product;
+
+    const metadataRes = {
+        title: '',
+        description: ''
+    };
+    if(product) {
+        metadataRes.title = product.metaTitle || product.name;
+        metadataRes.description = product.metaDescription || product.description;
+    } else {
+        metadataRes.title = 'Product';
+        metadataRes.description = '';
+    }
+
+    return metadataRes;
+}
+
+
 //动态路由中的参数 -- params
 export default async function ProductPage({
   params,
@@ -35,21 +81,58 @@ export default async function ProductPage({
   const { urlkey } = await params;
 
 
-  const resProductData = await serverGraphqlFetch<SingleProductResponse,{urlKey: string;}>({
-      // urlKey, // 产品名称
-      query: GET_PRODUCT_BY_URL_KEY, // gql查询语句
+  const resProductData = await getProductBaseInfo(urlkey);
+
+  const product = resProductData.data?.product;
+  const fetchProductError = resProductData.error;
+  if (fetchProductError && fetchProductError.extensions.status === 404) return notFound();
+  if(!product) throw new Error(fetchProductError?.message ?? 'Failed to get this product.');
+
+  const mediaImgs = (product.images.edges).map(
+    (edge: { node: ProductMediaType }) => {
+      return edge.node;
+    },
+  );
+
+  // 获取产品选项和变体
+  const productOptionsRes = serverGraphqlFetch<GetProductOptionsResponse,{id: number;}>({
+      query: GET_PRODUCT_OPTIONS,
       variables:{ 
-        urlKey: urlkey 
+        id: product._id 
       },
       takeAuthorization: false,
       cache: 'no-cache'
-      
+  }).then((res) => {
+      const resData = res.data?.product;
+      let errorMsg = null;
+      let productOptions: ProductOption[] = [];
+      let flexibleVariants: ResolvedVariant[] = []; 
+      if(!res.error) {
+        productOptions = resData?.productOptions
+          ? JSON.parse(resData.productOptions)
+          : [];
+
+        flexibleVariants = formatFlexibleVariants(resData?.flexibleVariants);
+          
+      } else {
+          errorMsg = res.error.message || 'Failed to get options for this product.';
+      }
+
+      return {
+        err: errorMsg,
+        productOptions,
+        flexibleVariants
+        
+      };
+  }).catch((err:unknown) => {
+      const productOptions: ProductOption[] = [];
+      const flexibleVariants: ResolvedVariant[] = [];
+      return {
+        err: err instanceof Error ?  err.message : 'Failed to get options for this product.',
+        productOptions,
+        flexibleVariants
+      };
   });
-
-  const product = resProductData.data?.product;
-  const fetchProductError = resProductData.error;
-  if (fetchProductError && fetchProductError.extensions.status === 404) return notFound();
-  if(!product) throw new Error(fetchProductError?.message ?? 'Get product data failed');
  
 
   const questionTotal = 73;
@@ -59,21 +142,11 @@ export default async function ProductPage({
   const productJsonLd = {
     "@context": BASE_SCHEMA_URL,
     "@type": PRODUCT_TYPE,
-    name: product?.name,
-    description: product?.description,
-    sku: product?.sku,
+    name: product.name,
+    description: product.description,
+    sku: product.sku,
   };
 
-  const mediaImgs = (product?.images?.edges ?? []).map(
-    (edge: { node: ProductMediaType }) => {
-      return edge.node;
-    },
-  );
-  const productOptions: ProductOption[] = product?.productOptions
-    ? JSON.parse(product?.productOptions)
-    : [];
-
-  const flexibleVariants = formatFlexibleVariants(product?.flexibleVariants);
 
 
 
@@ -89,17 +162,20 @@ export default async function ProductPage({
           <ProductMedia mediaData={mediaImgs} />
       </div>
 
+      <div className="box-border w-full px-4 mt-4">
+          <h3 className="text-ly-14 text-black leading-ly-22">
+            {product.name}
+          </h3>
+          <ProductText ProductText={product.shortDescription || ""} />
+      </div>
+
       <div className="">
+       
+            <ProductInforWrapper 
+                productBaseInfo={product}
+                optionsVariantsPromise={productOptionsRes}
+            />
 
-          <ProductInformation
-            key={product._id}
-            productId={product._id}
-            name={product?.name || ""}
-            productOptions={productOptions}
-            flexibleVariants={flexibleVariants}
-            isSaleable={product.isSaleable}
-            shortDescription={product.shortDescription || ""}
-          />
           <ShopServicePanel />
 
          <ProductDescription   htmlString={product?.feature || ""} />

File diff suppressed because it is too large
+ 69 - 0
src/app/(public)/product/_components/InstallmentTips.tsx


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

@@ -20,7 +20,7 @@ export default function NewUserBanner() {
             width="16"
             height="16"
             fill="#CCCCCC"
-            fill-opacity="0"
+            fillOpacity="0"
           ></rect>
           <ellipse
             cx="7.998697280883789"

+ 20 - 21
src/app/(public)/product/_components/ProductAddToCart.tsx

@@ -19,29 +19,29 @@ const ChatSvg = () => (
     <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"
+      strokeWidth="1.5"
+      strokeLinejoin="round"
+      strokeLinecap="round"
     ></path>
     <path
       stroke="rgba(0, 0, 0, 1)"
-      stroke-width="1.5"
-      stroke-linejoin="round"
-      stroke-linecap="round"
+      strokeWidth="1.5"
+      strokeLinejoin="round"
+      strokeLinecap="round"
       d="M7 9L16 9"
     ></path>
     <path
       stroke="rgba(0, 0, 0, 1)"
-      stroke-width="1.5"
-      stroke-linejoin="round"
-      stroke-linecap="round"
+      strokeWidth="1.5"
+      strokeLinejoin="round"
+      strokeLinecap="round"
       d="M7 13L16 13"
     ></path>
     <path
       stroke="rgba(0, 0, 0, 1)"
-      stroke-width="1.5"
-      stroke-linejoin="round"
-      stroke-linecap="round"
+      strokeWidth="1.5"
+      strokeLinejoin="round"
+      strokeLinecap="round"
       d="M7 17L12 17"
     ></path>
   </svg>
@@ -57,9 +57,9 @@ const ArrowDownSvg = () => (
   >
     <path
       stroke="rgba(0, 0, 0, 1)"
-      stroke-width="1.5"
-      stroke-linejoin="round"
-      stroke-linecap="square"
+      strokeWidth="1.5"
+      strokeLinejoin="round"
+      strokeLinecap="square"
       d="M18 9L12 15L6 9"
     ></path>
   </svg>
@@ -111,7 +111,7 @@ export function ProductAddToCart({
     <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]",
+        isShowBar ? "bottom-0" : "-bottom-[100%]",
       )}
     >
       {/* 顶部价格区域 */}
@@ -128,11 +128,10 @@ export function ProductAddToCart({
         <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={currencyCode}
-            />
+              <Price className="bg-ly-gold px-2 leading-ly-22 text-ly-14 mr-2"
+                amount={save}
+                currencyCode={currencyCode}
+              />
           )}
           {/* 现价 */}
           <Price

+ 74 - 0
src/app/(public)/product/_components/ProductInforWrapper.tsx

@@ -0,0 +1,74 @@
+"use client";
+
+import { useState, Suspense } from "react";
+import Big from 'big.js';
+import type { ProductOption, ResolvedVariant } from "@/types/products/productDetail";
+import type { ProductBaseInfo } from "@/types/products/productDetail";
+import NewUserBanner from "./NewUserBanner";
+import PromotionDiscountCardProps from "./PromotionDiscountCardProps ";
+import { InstallmentTips } from "./InstallmentTips";
+import { ProductPrice } from "./ProductPrice";
+import { ProductOptionsVariants } from "./ProductOptionsVariants";
+import { SkeletonProductOptions } from "./SkeletonProductOptions";
+
+interface PriceInfo {
+    totalNowPrice: number;
+    totalLinePrice: number;
+    save: number;
+}
+
+export function ProductInforWrapper({
+    productBaseInfo,
+    optionsVariantsPromise
+}: {
+    productBaseInfo: ProductBaseInfo; 
+    optionsVariantsPromise: Promise<{
+        err: string | null;
+        productOptions: ProductOption[];
+        flexibleVariants: ResolvedVariant[];
+    }>
+}) {
+
+    const productId = productBaseInfo._id;
+    const isSaleable = productBaseInfo.isSaleable;
+    const productPrice = productBaseInfo.price ? Number(productBaseInfo.price) : 0;
+    const minimumPrice = productBaseInfo.minimumPrice ? Number(productBaseInfo.minimumPrice) : 0;
+    const [priceInfo, setPriceInfo] = useState<PriceInfo>({
+        totalNowPrice: minimumPrice,
+        totalLinePrice: productPrice,
+        save: Big(minimumPrice).minus(productPrice).toNumber()
+    });
+    const onPriceChange = (data: PriceInfo) => {
+        setPriceInfo(data);
+    };
+
+
+    return (<>
+        <div className="box-border w-full pr-4 pl-4">
+
+        
+            <div className="mt-2">
+                <ProductPrice 
+                    totalNowPrice={priceInfo.totalNowPrice}
+                    totalLinePrice={priceInfo.totalLinePrice}
+                    save={priceInfo.save}
+                />
+            </div>
+        
+            <InstallmentTips totalPrice={priceInfo.totalNowPrice} />
+
+            <NewUserBanner />
+            {/* 产品Code */}
+            <PromotionDiscountCardProps productId="product-1001" />
+        </div>
+        <Suspense fallback={<SkeletonProductOptions />}>
+            <ProductOptionsVariants 
+                productId={productId}
+                isSaleable={isSaleable}
+                optionsVariantsPromise={optionsVariantsPromise}
+                onPriceChange={onPriceChange}
+            />
+        </Suspense>
+        
+    </>);
+}

+ 0 - 442
src/app/(public)/product/_components/ProductInformation.tsx

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

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

@@ -5,7 +5,7 @@ import { Swiper, SwiperSlide } from "swiper/react";
 import { Pagination } from "swiper/modules";
 import type { Swiper as SwiperType } from "swiper";
 import type { PaginationOptions } from "swiper/types";
-import type { ProductMediaType } from "@/components/catalog/type";
+import type { ProductMediaType } from "@/types/products/productDetail";
 import { getImageUrl } from "@/utils/constants";
 import { useState, useRef } from "react";
 

+ 371 - 0
src/app/(public)/product/_components/ProductOptionsVariants.tsx

@@ -0,0 +1,371 @@
+"use client";
+
+import { useState, useMemo, use, useEffect, useRef } from "react";
+import clsx from "clsx";
+import { useRouter } from "next/navigation";
+import type { ProductOption, ResolvedVariant } from "@/types/products/productDetail";
+import { useCustomToast } from "@utils/hooks/useToast";
+import { useAddProduct } from "@utils/hooks/useAddToCart";
+import { useStore } from "@utils/hooks/useConfig";
+import {
+    isValueAvailable,
+    isOptionValueAvailable,
+    getFirstAvailable,
+    getAvailableVariants,
+    isCombinationAvailable,
+    findNearestAvailableVariant,
+    calculateSelectedVariantInfo
+} from "@/utils/variantTools";
+import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
+import { ProductAddToCart } from "@/app/(public)/product/_components/ProductAddToCart";
+import Guide from "./popup/Guide";
+
+
+export function ProductOptionsVariants({
+    productId,
+    isSaleable,
+    optionsVariantsPromise,
+    onPriceChange
+}: {
+    productId: number;
+    isSaleable: string | undefined;
+    optionsVariantsPromise: Promise<{
+        err: string | null;
+        productOptions: ProductOption[];
+        flexibleVariants: ResolvedVariant[];
+    }>;
+    onPriceChange: (data: {
+        totalNowPrice: number;
+        totalLinePrice: number;
+        save: number;
+    }) => void;
+}) {
+
+    const {productOptions, flexibleVariants, err: optionsVariantsError} = use(optionsVariantsPromise);
+
+    const { appendProductToCart } = useAddProduct();
+    const { showToast } = useCustomToast();
+    const router = useRouter();
+    const {store} = useStore();
+    const currencyCode = store.currentCurrency;
+
+    const hasInit = useRef(false);
+
+    // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
+    const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0]?.id);
+
+    const [productQty, setProductQty] = useState(1);
+
+    const [guideModalOpen, setGuideModalOpen] = useState(false);
+    // 获取所有可用变体(quantity > 0)
+    const availableVariants = useMemo(() => {
+        return getAvailableVariants(flexibleVariants);
+    }, [flexibleVariants]);
+
+    // 当前选中的选项值映射:option_id -> value_id
+    const [selected, setSelected] = useState<Record<number, number>>(() => {
+        let res: Record<number, number> = {};
+
+        const defaultSelected = getFirstAvailable(productOptions, flexibleVariants);
+        if (defaultSelected) {
+            res = defaultSelected;
+        } else {
+            // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
+            productOptions.forEach((opt) => {
+                res[opt.id] = opt.values[0]?.id;
+            });
+        }
+        console.log("set default selected");
+        return res;
+    });
+
+    // 计算每个选项值的禁用状态
+    const disabledMap = useMemo(() => {
+        const map: Record<number, boolean> = {};
+        if (Object.keys(selected).length === 0) return map; // 没有选中的项
+
+        productOptions.forEach((option) => {
+            option.values.forEach((value) => {
+                // 规则:如果该选项组是最近点击的组,校验组中的值是否存在可用变体
+                if (option.id === lastClickedOptionId) {
+                    //   map[value.id] = false;
+                    map[value.id] = !isValueAvailable(value.id, availableVariants);
+                } else {
+                    // 校验其他组的选项值是否可用
+                    map[value.id] = !isOptionValueAvailable(
+                        option.id,
+                        value.id,
+                        selected,
+                        productOptions,
+                        availableVariants,
+                    );
+                }
+            });
+        });
+        return map;
+    }, [selected, lastClickedOptionId, productOptions, availableVariants]);
+
+    // 处理选项点击
+    const handleOptionClick = (optionId: number, valueId: number) => {
+        // 更新前需要判断当前选中的组合是否可以购买,如果可以购买正常更新;如果不能购买查找可以购买的组合然后更新
+        let newSelected: Record<number, number> = {
+            ...selected,
+            [optionId]: valueId,
+        };
+        const selectedValueIds = Object.values(newSelected);
+        if (!isCombinationAvailable(selectedValueIds, availableVariants)) {
+            newSelected = findNearestAvailableVariant(
+                optionId,
+                valueId,
+                { ...newSelected },
+                productOptions,
+                availableVariants,
+            );
+        }
+        setSelected({ ...newSelected });
+        // 记录当前点击的选项组
+        setLastClickedOptionId(optionId);
+
+        const res = calculateSelectedVariantInfo(newSelected, flexibleVariants, productQty);
+        onPriceChange({
+            totalLinePrice: res.totalLinePrice,
+            totalNowPrice: res.totalNowPrice,
+            save: res.save
+        });
+    };
+
+    // 判断当前选中的组合是否可购买(用于控制购买按钮等)
+    const isCurrentSelectionAvailable = useMemo(() => {
+        let res = true;
+        if (isSaleable !== "1") {
+            res = false;
+        } else {
+            const selectedIds = Object.values(selected);
+            if (selectedIds.length !== productOptions.length) {
+                res = false;
+            } else {
+                res = isCombinationAvailable(selectedIds, availableVariants);
+            }
+        }
+        return res;
+    }, [selected, productOptions, flexibleVariants, isSaleable]);
+
+    // 获取当前选中变体和价格等信息
+    // useEffect和useMemo的执行时机不同,useMemo早于useEffect执行,如果使用useEffect初始化selected,则需要对selected做判断,当为空对象时返回占位对象
+    const currentVariantInfo = useMemo(() => {
+
+
+        return calculateSelectedVariantInfo(selected, flexibleVariants, productQty);
+
+        /*
+        const selectedIds = Object.values(selected);
+        let totalLinePrice = 0;
+        let totalNowPrice = 0;
+        let save = 0;
+
+        const variant = flexibleVariants.find((v) => {
+            const vIds = v.optionValues.map((ov) => ov.id);
+            return (
+                selectedIds.length === vIds.length &&
+                selectedIds.every((id) => vIds.includes(id))
+            );
+        });
+        if (selectedIds.length === 0 || variant === undefined) {
+            // 尚未初始化,返回一个安全占位对象
+            return {
+                variant: null,
+                totalLinePrice: 0,
+                totalNowPrice: 0,
+                save: 0,
+            };
+        }
+
+        if (variant.priceIndices && variant.priceIndices.length > 0) {
+            totalLinePrice = Big(variant.priceIndices[0].regular_min_price).times(productQty).toNumber();
+            totalNowPrice = Big(variant.priceIndices[0].min_price).times(productQty).toNumber();
+        }
+        if (totalLinePrice !== totalNowPrice) {
+            save = Big(totalNowPrice).minus(totalLinePrice).toNumber();
+        }
+        
+
+        return {
+            variant,
+            totalLinePrice:   totalLinePrice,
+            totalNowPrice:   totalNowPrice,
+            save:  save,
+        };
+        */
+    }, [selected, flexibleVariants, productQty]);
+
+
+    const handlerProductQty = (action: string) => {
+        let newQty = 0;
+        if (action === "increase") {
+            newQty = productQty + 1;
+        } else if (action === "decrease" && productQty > 1) {
+            newQty = productQty - 1;
+        }
+        if(newQty) {
+            setProductQty(newQty);
+            const res = calculateSelectedVariantInfo(selected, flexibleVariants, newQty);
+            onPriceChange({
+                totalLinePrice: res.totalLinePrice,
+                totalNowPrice: res.totalNowPrice,
+                save: res.save
+            });
+        }
+    };
+
+    async function addProductToCart(action:string = 'addtocart') {
+        overlayLoading.start();
+        const res = await appendProductToCart({
+            productId: productId, 
+            quantity: productQty,
+            variantId: currentVariantInfo.variant?._id,
+        });
+        console.log('onAddToCart result --- ', res);
+        overlayLoading.stop();
+        if(action === 'buynow') {
+            if(res.data) {
+                const responseData = res.data;
+                if(responseData.success) {
+                    router.push('/checkout');
+                }
+            }
+        }
+    } 
+
+    const addToCartHandler = async () => {
+   
+        if(!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+
+        if(currentVariantInfo.variant) {
+            addProductToCart();
+        }
+    };
+
+
+    const buyNowHandler = () => {
+        if (!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+        if (currentVariantInfo.variant) {
+            addProductToCart("buynow");
+        }
+    };
+  
+
+    useEffect(() => {
+        if(hasInit.current) return;
+        const initialPriceInfo = calculateSelectedVariantInfo(
+            selected,
+            flexibleVariants,
+            productQty,
+        );
+        hasInit.current = true;
+        onPriceChange({
+            totalLinePrice: initialPriceInfo.totalLinePrice,
+            totalNowPrice: initialPriceInfo.totalNowPrice,
+            save: initialPriceInfo.save
+        });
+        
+    }, [
+        selected,
+        flexibleVariants,
+        productQty,
+        onPriceChange,
+    ]);
+
+    if(optionsVariantsError) {
+        return (<>
+            <div className="box-border w-full pr-4 pl-4 mt-4 relative">
+                <p className="text-ly-14 text-center text-ly-errorcolor">
+                    {optionsVariantsError}
+                </p>
+            </div>
+        </>);
+    }
+
+    return (<>
+        <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} className="mt-5 first:mt-0">
+                    <div className="text-ly-14 font-medium ">{option.label}</div>
+
+                    <div className="flex flex-wrap gap-3 mt-3">
+                    {option.values.map((value) => {
+                        const isSelected = selected[option.id] === value.id;
+                        const isDisabled = disabledMap[value.id] || false;
+                        return (
+                        <button
+                            key={value.id}
+                            type="button"
+                            onClick={() => !isDisabled && handleOptionClick(option.id, value.id)}
+                            className={clsx("text-ly-14 leading-ly-24 border border-solid rounded-lg pt-1.5 pb-1.5 pl-3 pr-3 bg-white", {
+                                "border-ly-green text-ly-green": isSelected,
+                                "border-black text-black": !isSelected,
+                                "opacity-40 cursor-not-allowed strokeline-bg-disabled": isDisabled,
+                            })}
+                            disabled={isDisabled}
+                        >
+                            {value.label}
+                        </button>
+                        );
+                    })}
+                    </div>
+                </div>
+            ))}
+
+            <div className="w-full flex justify-between items-center mt-3">
+                <div className="text-ly-14 text-black  font-medium ">Quantity*</div>
+                <div className="flex border border-solid rounded-lg">
+                    <button onClick={() => handlerProductQty("decrease")}
+                        type="button"
+                        className="flex-none w-7.5 h-11 flex items-center justify-center"
+                    >
+                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8"/>
+                        </svg>
+                    </button>
+                    <input
+                        type="text"
+                        className="w-15 h-11 leading-ly-36 text-center"
+                        value={productQty}
+                        readOnly
+                    />
+                    <button onClick={() => handlerProductQty("increase")}
+                        type="button"
+                        className="flex-none w-7.5 h-11 flex items-center justify-center"
+                    >
+                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M8.02026 3.3335L8.00806 12.6668"/>
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8"/>
+                        </svg>
+                    </button>
+                </div>
+            </div>
+        </div>
+        <ProductAddToCart
+            isAvailable={isCurrentSelectionAvailable}
+            onAddToCart={addToCartHandler}
+            onBuyNow={buyNowHandler}
+            currencyCode={currencyCode}
+            priceInfo={{
+                totalNowPrice: currentVariantInfo.totalNowPrice,
+                totalLinePrice: currentVariantInfo.totalLinePrice,
+                save: currentVariantInfo.save,
+            }}
+        />
+        <Guide open={guideModalOpen} onClose={() => setGuideModalOpen(false)} />
+    </>);
+}

+ 42 - 0
src/app/(public)/product/_components/ProductPrice.tsx

@@ -0,0 +1,42 @@
+"use client";
+
+import { useStore } from "@utils/hooks/useConfig";
+import { Price } from "@components/theme/ui/Price";
+
+export function ProductPrice({
+    totalNowPrice,
+    totalLinePrice,
+    save
+}: {
+    totalNowPrice: number;
+    totalLinePrice: number;
+    save: number;
+}) {
+    const {store} = useStore();
+    const currencyCode = store.currentCurrency;
+
+    return (
+        <div className="flex items-center">
+            <Price
+                className="text-ly-16 leading-ly-24 font-bold"
+                amount={totalNowPrice}
+                currencyCode={currencyCode}
+            />
+            {totalLinePrice !== totalNowPrice && (
+                <>
+                <Price
+                    className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
+                    amount={totalLinePrice}
+                    currencyCode={currencyCode}
+                />
+                <Price
+                    className="text-ly-12 leading-ly-16 bg-ly-gold pr-1.5 pl-1.5 ml-3"
+                    amount={save}
+                    currencyCode={currencyCode}
+                />
+                </>
+            )}
+        </div>
+
+    );
+}

+ 3 - 5
src/app/(public)/product/_components/ProductReviewSection.tsx

@@ -1,12 +1,11 @@
 import ReviewDetail from "@/app/(public)/product/_components/review/ReviewDetail";
 import { NoReview } from "@/app/(public)/product/_components/review/NoReview";
-// import { getProductReviews } from "@utils/hooks/getProductReviews";
-import type { ProductReviewList } from "@/types/products/productDetail";
+import type { ProductReviewNode } from "@/types/products/review";
 import React from "react";
 // 组件入参TS规范
 interface ProductReviewSectionProps {
   productId: string;
-  reviews: ProductReviewList;
+  reviews: ProductReviewNode[];
   modalOpen: boolean;
   closeModal: () => void;
   onOpenBaseModal: () => void;
@@ -23,8 +22,7 @@ function ProductReviewSection({
   onOpenBaseModal,
   onChangeTab,
 }: ProductReviewSectionProps) {
-  // const getAllreviews = await getProductReviews(productId)
-  console.log("productId-------------:", productId, reviews);
+
   return (
     <>
       <div className="box-border w-full">

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

@@ -40,9 +40,9 @@ export default function ProductText({ ProductText }: { ProductText: string }) {
         >
           <path
             stroke="rgba(0, 0, 0, 1)"
-            stroke-width="1.5"
-            stroke-linejoin="round"
-            stroke-linecap="square"
+            strokeWidth="1.5"
+            strokeLinejoin="round"
+            strokeLinecap="square"
             d="M18 9L12 15L6 9"
           ></path>
         </svg>

+ 32 - 0
src/app/(public)/product/_components/SkeletonProductOptions.tsx

@@ -0,0 +1,32 @@
+
+
+export function SkeletonProductOptions() {
+
+
+    return (<>
+        <div className="box-border w-full pr-4 pl-4 mt-4 relative animate-pulse">
+           
+                <div>
+                    <div className="w-20 h-3 bg-skeletonbg"></div>
+
+                    <div className="flex flex-wrap gap-3 mt-3">
+                        {[1,2,3,4,5,6,7,8,9,10].map((value) => {
+                            return (
+                                <button
+                                    key={value}
+                                    type="button"
+                                    className="h-9 w-20 rounded-lg bg-skeletonbg"
+                                ></button>
+                            );
+                        })}
+                    </div>
+                </div>
+            
+
+            <div className="w-full flex justify-between items-center mt-3">
+                <div className="h-3.5 w-20 bg-skeletonbg"></div>
+                <div className="w-40 h-11 bg-skeletonbg rounded-lg"></div>
+            </div>
+        </div>
+    </>);
+}

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

@@ -2,6 +2,7 @@
 import { useState, useMemo, useEffect, useCallback } from "react";
 import ProductReviewSection  from "@/app/(public)/product/_components/ProductReviewSection";
 import Image from "next/image";
+import type { ProductReviewsRestfulResponse, ProductReviewNode } from "@/types/products/review";
 import ReviewAdd from "@/app/(public)/product/_components/review/ReviewAdd";
 import FaqList, { type FaqItem } from "./question/FaqList";
 import HairPhotoGallery from "./review/HairPhotoGallery";
@@ -52,7 +53,7 @@ export default function SwitchButton({
 }: ReviewQuestionTabProps) {
     // const [uuid, setUuidId] = useState<string>('');
   // 自己通过接口拿到,不再由父组件传入
-  const [reviewList, setReviewList] = useState<any[]>([]);
+  const [reviewList, setReviewList] = useState<ProductReviewNode[]>([]);
   const [reviewTotal, setReviewTotal] = useState<number>(0);
 
   useEffect(() => {
@@ -67,7 +68,7 @@ export default function SwitchButton({
   const fetchReviews = async () => {
     try {
       const clientUuid = getUuId();
-      const res = await clientFetch(
+      const res = await clientFetch<ProductReviewsRestfulResponse>(
         `/api/shop/products/${productId}/reviews?page=1&per_page=10&has_images=0&sort=all&client_id=${clientUuid}`,
         { signal } // 绑定中断信号
       );

+ 7 - 2
src/app/(public)/product/_components/review/AddRatingStar.tsx

@@ -3,7 +3,6 @@
 import { useState, useEffect } from "react";
 import clsx from "clsx";
 import { StarIcon } from "@heroicons/react/24/solid";
-import type { RatingTypes } from "@/components/catalog/type";
 
 
 export const AddRatingStar = ({
@@ -12,7 +11,13 @@ export const AddRatingStar = ({
     size = "size-6",
     className,
     onChange
-}: RatingTypes) => {
+}: {
+    length?: number;
+    value?: number;
+    size?: string;
+    className?: string;
+    onChange?: (value: number) => void;
+}) => {
 
     const [internalValue, setInternalValue] = useState(value ?? 0);
     const [hovered, setHovered] = useState<number | null>(null);

+ 7 - 36
src/app/(public)/product/_components/review/ReviewDetail.tsx

@@ -11,19 +11,14 @@ import { isArray } from "@/utils/type-guards";
 import { Avatar } from "@heroui/avatar";
 import clsx from "clsx";
 import React, { type FC, useState } from "react";
-import type { ProductReviewNode } from "@/components/catalog/type";
+import type { ProductReviewNode } from "@/types/products/review";
 import ReviewModal from "./ReviewModal";
-// import { ReviewTabType } from "@/types/products/review";
 import { useCustomToast } from "@utils/hooks/useToast";
 import { clientFetch } from "@/lib/restApiClient";
 import { getUuId } from "@/utils/helper";
-interface ProductReviewEdge {
-  __typename: "ProductReviewEdge";
-  node: ProductReviewNode;
-}
 
 interface ReviewDetailProps {
-  reviewDetails: ProductReviewEdge[];
+  reviewDetails: ProductReviewNode[];
   totalReview: number;
   productId: string;
   modalOpen: boolean;
@@ -44,7 +39,7 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
   onOpenBaseModal,
 }) => {
  
-  const [visibleCount, setVisibleCount] = useState(5);
+
   const { showToast } = useCustomToast();
   const [localLikeMap, setLocalLikeMap] = useState<
     Record<string, { liked: boolean; like_count: number }>
@@ -57,11 +52,10 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
       like_count: item.like_count,
     };
   };
-  // reviews 处理的是原来的接口逻辑
-  const reviews: any[] = reviewDetails?.map((edge) => edge.node) || [];
-  // const { reviewAvg, ratingCounts } = getReviews(reviews);
 
-  const visibleReviews = reviewDetails.slice(0, visibleCount);
+
+
+  const visibleReviews = reviewDetails.slice(0, 5);
   console.log("visibleReviews-------------------:", visibleReviews);
 
   const handleLikeClick = async (item: any) => {
@@ -322,30 +316,7 @@ const ReviewDetail: FC<ReviewDetailProps> = ({
               );
             })}
 
-            {false && reviews.length > visibleCount && (
-              <div className="py-4">
-                <button
-                  onClick={() => setVisibleCount((prev) => prev + 5)}
-                  className="flex items-start gap-2 text-primary-600 cursor-pointer hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium transition-colors duration-200 group"
-                >
-                  <span>Load More111</span>
-                  <svg
-                    className="w-4 h-4 transition-transform duration-200 group-hover:translate-x-1 mt-1"
-                    fill="none"
-                    stroke="currentColor"
-                    viewBox="0 0 24 24"
-                    xmlns="http://www.w3.org/2000/svg"
-                  >
-                    <path
-                      strokeLinecap="round"
-                      strokeLinejoin="round"
-                      strokeWidth={2}
-                      d="M9 5l7 7-7 7"
-                    />
-                  </svg>
-                </button>
-              </div>
-            )}
+
             <div className="mt-6 mb-8 flex justify-center">
               <button
                 onClick={onOpenBaseModal}

+ 8 - 67
src/app/(public)/product/_components/review/ReviewModal.tsx

@@ -2,8 +2,9 @@
 import React, { useState, useRef, useEffect, useCallback } from "react";
 import type {
   ReviewTabType,
-  ProductReviewList,
   GalleryItem,
+  ProductReviewNode,
+  ProductReviewsRestfulResponse
 } from "@/types/products/review";
 import ReviewImageGalleryModal from "./ReviewImageGalleryModal";
 import Image from "next/image";
@@ -27,7 +28,7 @@ async function fetchReviewApi(
   tab: ReviewTabType,
   pageNum: number,
   guestId: string | null,
-): Promise<{ list: ProductReviewList; total: number }> {
+): Promise<{ list: ProductReviewNode[]; total: number }> {
   // 模拟网络延迟,模拟真实请求loading
   // await new Promise((resolve) => setTimeout(resolve, 700));
   //     return {
@@ -40,7 +41,7 @@ async function fetchReviewApi(
   if (tab == "photos") {
     has_images = 1;
   }
-  const res = await clientFetch(
+  const res = await clientFetch<ProductReviewsRestfulResponse>(
     `/api/shop/products/${pid}/reviews?page=${pageNum}&per_page=${PAGE_SIZE}&has_images=${has_images}&sort=${tab}&client_id=${guestId}`,
   );
   console.log(
@@ -48,71 +49,11 @@ async function fetchReviewApi(
     res?.data,
   );
 
-  // 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`,
-  //     );
-  //   }
-
-  //   // 随机星级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筛选数据
   const filteredList = [...res?.data];
   const 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);
 
   return {
     list: filteredList,
@@ -129,7 +70,7 @@ const ReviewModal: React.FC<ReviewModalProps> = React.memo(
     activeTab,
   }) {
     // 全部原始评论数据(接口返回完整数据缓存)
-    const [fullReviewList, setFullReviewList] = useState<ProductReviewList>([]);
+    const [fullReviewList, setFullReviewList] = useState<ProductReviewNode[]>([]);
     // 分页状态
     const [page, setPage] = useState(1);
     const [loading, setLoading] = useState(false);
@@ -212,11 +153,11 @@ const ReviewModal: React.FC<ReviewModalProps> = React.memo(
     // 生成图库一维图片数组
     const buildGalleryList = useCallback((): GalleryItem[] => {
       const allGallery: GalleryItem[] = [];
-      filterReviewList().forEach((edge: any, reviewIdx) => {
-        edge.attachments.forEach((imgUrl: any) => {
+      filterReviewList().forEach((edge, reviewIdx) => {
+        edge.attachments.forEach((imgUrl) => {
           allGallery.push({
             reviewIndex: reviewIdx,
-            imageUrl: imgUrl,
+            imageUrl: imgUrl.url,
             review: edge,
           });
         });

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

@@ -196,65 +196,6 @@ const Recommend = () => {
                     {/* 购物车图标 */}
                     <OpenAddToCartModalButton
                         productId={product.id}
-                      icon={
-                        <svg
-                          xmlns="http://www.w3.org/2000/svg"
-                          width="32"
-                          height="32"
-                          viewBox="0 0 32 32"
-                          fill="none"
-                        >
-                          <rect
-                            x="0"
-                            y="0"
-                            width="32"
-                            height="32"
-                            rx="8"
-                            fill="#036141"
-                          ></rect>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                          ></path>
-                          <ellipse
-                            cx="11.066040515899658"
-                            cy="23.199928283691406"
-                            rx="1.4000248908996582"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <ellipse
-                            cx="23.1998291015625"
-                            cy="23.199928283691406"
-                            rx="1.4000244140625"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M15.2656 15.2665L18.999 15.2665"
-                          ></path>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M17.1348 17.1332L17.1348 13.3999"
-                          ></path>
-                        </svg>
-                      }
                     />
                   </div>
                 </div>

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

@@ -10,7 +10,7 @@ import { SortByFields } from "@/utils/constants";
 import type {
   SearchProductsResponse,
   SearchProductsVariables,
-} from "@/components/catalog/type";
+} from "@/types/category/type";
 import { MobileSearchBar } from "@components/layout/navbar/MobileSearch";
 import HotSearch from "./_components/HotSearch";
 import NewProductGridItems from "@/components/catalog/product/NewProductGridItems";

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

@@ -64,65 +64,6 @@ export default function NewProductGridItems({
                           {/* 购物车图标 */}
                           <OpenAddToCartModalButton
                               productId={product.id}
-                            icon={
-                              <svg
-                                xmlns="http://www.w3.org/2000/svg"
-                                width="32"
-                                height="32"
-                                viewBox="0 0 32 32"
-                                fill="none"
-                              >
-                                <rect
-                                  x="0"
-                                  y="0"
-                                  width="32"
-                                  height="32"
-                                  rx="8"
-                                  fill="#036141"
-                                ></rect>
-                                <path
-                                  stroke="rgba(255, 255, 255, 1)"
-                                  stroke-width="1.3333333333333333"
-                                  stroke-linejoin="round"
-                                  stroke-linecap="round"
-                                  d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                                ></path>
-                                <ellipse
-                                  cx="11.066040515899658"
-                                  cy="23.199928283691406"
-                                  rx="1.4000248908996582"
-                                  ry="1.4000015258789062"
-                                  stroke="rgba(255, 255, 255, 1)"
-                                  stroke-width="1.3333333333333333"
-                                  stroke-linejoin="round"
-                                  stroke-linecap="round"
-                                ></ellipse>
-                                <ellipse
-                                  cx="23.1998291015625"
-                                  cy="23.199928283691406"
-                                  rx="1.4000244140625"
-                                  ry="1.4000015258789062"
-                                  stroke="rgba(255, 255, 255, 1)"
-                                  stroke-width="1.3333333333333333"
-                                  stroke-linejoin="round"
-                                  stroke-linecap="round"
-                                ></ellipse>
-                                <path
-                                  stroke="rgba(255, 255, 255, 1)"
-                                  stroke-width="1.3333333333333333"
-                                  stroke-linejoin="round"
-                                  stroke-linecap="round"
-                                  d="M15.2656 15.2665L18.999 15.2665"
-                                ></path>
-                                <path
-                                  stroke="rgba(255, 255, 255, 1)"
-                                  stroke-width="1.3333333333333333"
-                                  stroke-linejoin="round"
-                                  stroke-linecap="round"
-                                  d="M17.1348 17.1332L17.1348 13.3999"
-                                ></path>
-                              </svg>
-                            }
                           />
                         </div>
             </Grid.Item>

+ 6 - 1
src/components/catalog/product/ProductsSection.tsx

@@ -3,8 +3,13 @@
 import Grid from "../../theme/ui/grid/Grid";
 import { getImageUrl } from "@/utils/constants";
 import { ProductCard } from "./ProductCard";
-import type { ProductsSectionProps } from "../type";
+import type { ProductCoreNode } from "@/types/products/productDetail";
 
+type ProductsSectionProps = {
+  title: string;
+  description: string;
+  products: ProductCoreNode[];
+};
 
 export function ProductsSection({ title, description, products }: ProductsSectionProps) {
   if (!products?.length) return null;

+ 2 - 2
src/components/catalog/product/RelatedProductsSection.tsx

@@ -1,6 +1,6 @@
 import { GET_RELATED_PRODUCTS } from "@/graphql";
 import { ProductsSection } from "./ProductsSection";
-import type { SingleProductResponse } from "@components/catalog/type";
+import type { GetRelatedProductsResponse } from "@/types/products/productDetail";
 import {serverGraphqlFetch} from "@utils/bagisto/index";
 
 export async function RelatedProductsSection({
@@ -10,7 +10,7 @@ export async function RelatedProductsSection({
 }) {
     async function getRelatedProduct(urlKey: string) {
       try {
-        const {data:dataById} = await serverGraphqlFetch<SingleProductResponse,{urlKey:string;first:number;}>({
+        const {data:dataById} = await serverGraphqlFetch<GetRelatedProductsResponse,{urlKey:string;first:number;}>({
           // urlKey,
           query:GET_RELATED_PRODUCTS,
           variables:{

+ 0 - 420
src/components/catalog/type.ts

@@ -1,420 +0,0 @@
-export interface SingleProductResponse {
-  product: ProductNode | null;
-}
-export interface ProductOptionValue {
-  code: string;
-  id: number;
-  label: string;
-  meta: [] | Record<string, unknown>;  // Record<string, unknown>也可以写成 { [key: string]: unknown },表示任意对象,unknow比any更安全
-  position: number;
-}
-
-export interface ProductOption {
-  code: string;
-  id: number;
-  is_required: boolean;
-  label: string;
-  meta: [] | Record<string, unknown>;
-  position: number;
-  type: string;
-  values: Array<ProductOptionValue>;
-}
-
-export interface ProductFlexibleVariantValue {
-    code: string;
-    id: number;
-    label: string;
-    option_code: string;
-    option_id: number;
-}
-
-export interface ProductFlexibleVariantPriceIndices{
-  id: number;
-  min_price: string;
-  regular_min_price:string;
-}
-
-export interface VatiantImages {
-  id: number;
-  position: number;
-  url: string;
-}
-
-export interface ProductFlexibleVariant {
-  id: string;
-  _id: number;
-  sku: string;
-  variantImages: string | null | VatiantImages[]; 
-  optionValues: string | Array<ProductFlexibleVariantValue>
-  quantity:number;
-  price: string;
-  priceIndices: string | null | Array<ProductFlexibleVariantPriceIndices>;
-}
-
-export interface ProductMediaType{
-  id: string;
-  path: string;
-  publicPath: string;
-  position: string;
-}
-
-// 定义解析后的类型(根据最初类型裁剪)
-export type ResolvedVariant = Omit<ProductFlexibleVariant, 'optionValues' | 'variantImages | priceIndices'> & {
-  optionValues: ProductFlexibleVariantValue[];
-  variantImages: VatiantImages[]; // 去掉 string 可能性
-  priceIndices: ProductFlexibleVariantPriceIndices[];
-};
-
-export interface ProductSectionNode {
-  isSaleable: string | undefined;
-  id: string;
-  sku: string;
-  type: string;
-  urlKey?: string;
-  name?: string;
-  baseImageUrl?: string;
-  price?: string | number;
-  specialPrice?: string;
-  images?: {
-    edges: Array<{
-      node: {
-        publicPath: string;
-      };
-    }>;
-  };
-}
-
-export interface ProductVariantNode {
-  id: string;
-  sku: string;
-  name?: string;
-  price?: number;
-  attributeValues?: {
-    edges: Array<{
-      node: {
-        attribute?: {
-          code?: string;
-        };
-        value?: string;
-      };
-    }>;
-  };
-}
-
-export interface ProductNode {
-  variants: {
-    edges: Array<{ node: ProductVariantNode }>;
-  };
-  id: string;
-  _id: number;
-  sku: string;
-  type: string;
-  name?: string;
-  urlKey?: string;
-  isSaleable?: string | undefined;
-  feature?:string;
-  description?: string;
-  shortDescription?: string;
-  specialPrice?: string;
-  metaTitle?: string;
-  baseImageUrl?: string;
-  images?: {
-    edges: Array<{ node: ProductMediaType }>
-  };
-  price?: string | number | { value?: number; currencyCode?: string } | null;
-  minimumPrice?: string | number;
-  formattedPrice?: string;
-  formattedSpecialPrice?: string | null;
-  formattedMinimumPrice?: string;
-  productOptions?: string | null;
-  flexibleVariants?: Array<ProductFlexibleVariant>;
-  priceHtml?: {
-    currencyCode?: string;
-  };
-  superAttributes?: {
-    edges: Array<{ node: ProductReviewNode }>;
-  };
-  reviews?: {
-    edges: Array<{ node: ProductReviewNode }>;
-  };
-  relatedProducts?: {
-    edges: Array<{ node: ProductSectionNode }>;
-  };
-  crossSells?: {
-    edges: Array<{ node: ProductSectionNode }>;
-  };
-  upSells?: {
-    edges: Array<{ node: ProductSectionNode }>;
-  };
-}
-
-export interface ProductsResponse {
-  products: {
-    edges: Array<{ node: ProductNode }>;
-    pageInfo: {
-      endCursor: string;
-      startCursor: string;
-      hasNextPage: boolean;
-      hasPreviousPage: boolean;
-    };
-    totalCount: number;
-  };
-}
-export interface CategoryAttrFilterItem  {
-  _id: number;
-  code: string;
-  adminName: string;
-  type: string;
-  swatchType: string | null;
-  position: number | null;
-  minPrice: string;
-  maxPrice: string;
-  options: {
-    edges: Array<{
-      node: {
-        _id: string;
-        adminName: string;
-        sortOrder: number;
-        swatchValue: string | null;
-        productCount:number;
-      };
-    }>;
-  };
-};
-export interface GetCategoryAttrFiltersResult  {
-  categoryAttributeFilters: {
-    edges: Array<{ node: CategoryAttrFilterItem }>;
-    pageInfo: {
-      hasNextPage: boolean;
-      endCursor: string | null;
-    };
-  };
-};
-export type CategoryProductNode = {
-  _id: number;
-  id: string;
-  sku: string;
-  name: string;
-  type: string;
-  price: string;
-  minimumPrice: string;
-  maximumPrice: string;
-  formattedPrice: string;
-  baseImageUrl: string;
-  status: string;
-  new: string;
-  featured: string;
-  urlKey: string;
-  isInWishlist: string;
-};
-
-export type CategoryProductsResult = {
-  categoryProducts: {
-    totalCount: number;
-    description: string;
-    banner: string;
-    pageInfo:{
-      endCursor:string,
-      hasNextPage: boolean
-    }
-    products: Array<{
-      cursor: string;
-      node: CategoryProductNode;
-    }>;
-  };
-};
-export type PageInfo = {
-  endCursor: string;
-  hasNextPage: boolean;
-};
-export interface SearchProductsVariables {
-    query: string;
-    suggest?: boolean;
-    filter?: string;
-    sortKey?: string;
-    reverse?: boolean;
-    page?: number;
-    first?: number;
-    last?: number;
-    after?: string;
-    before?: string;
-    channel?: string;
-    locale?: string;
-}
-export interface SearchProductsResponse {
-  search: {
-    edges: Array<{ node: ProductNode }>;
-    pageInfo: {
-      endCursor: string;
-      startCursor: string;
-      hasNextPage: boolean;
-      hasPreviousPage: boolean;
-      currentPage: number;
-      totalPages: number;
-    };
-    totalCount: number;
-
-    searchMetadata: {
-      originalQuery: string;
-      effectiveQuery: string;
-      resultMessage: string;
-      searchInsteadMessage: string;
-    }
-  };
-}
-
-export interface ProductSectionNode {
-  id: string;
-  _id: number;
-  sku: string;
-  type: string;
-  urlKey?: string;
-  name?: string;
-  baseImageUrl?: string;
-  minimumPrice?: string | number;
-  price?: string | number;
-  specialPrice?: string;
-  images?: {
-    edges: Array<{
-      node: {
-        publicPath: string;
-      };
-    }>;
-  };
-}
-
-export type ProductsSectionProps = {
-  title: string;
-  description: string;
-  products: ProductSectionNode[];
-};
-
-export interface ProductFilterAttributeResponse {
-  attribute: {
-    id: string;
-    code: string;
-    options: {
-      edges: Array<{
-        node: {
-          id: string;
-          adminName: string;
-        };
-      }>;
-    };
-  };
-}
-
-export type ProductReview = {
-  rating: number;
-};
-
-export interface ProductReviewNode {
-  __typename: "ProductReview";
-  name: string;
-  title: string;
-  rating: number;
-  comment: string;
-  createdAt: string;
-  images?: {
-    url: string;
-    reviewId: string;
-  }[];
-  customer?: {
-    name: string;
-    imageUrl: string;
-  };
-}
-
-export interface ProductData {
-  urlKey: string;
-  variants?: {
-    edges?: {
-      node?: {
-        attributeValues?: {
-          edges?: {
-            node?: {
-              attribute?: {
-                code?: string;
-              };
-              value?: string;
-            };
-          }[];
-        };
-        id?: string;
-        priceBaseImageUrl?: string;
-        name?: string;
-        name_id?: string;
-        sku?: string;
-        type?: string;
-        color?: string;
-        size?: string;
-      };
-    }[];
-  } | null;
-  name?: string;
-  price?: { value?: number; currencyCode?: string } | number | null;
-  priceHtml?: { currencyCode?: string } | null;
-  averageRating?: number;
-  type?: string;
-  reviewCount?: number;
-  minimumPrice?: string;
-  specialPrice?: string;
-  shortDescription?: string;
-  status?: boolean;
-  id?: string;
-  description?: string;
-  configutableData?: {
-    attributes?: unknown[];
-    index?: unknown[];
-  } | null;
-}
-
-export interface AttributeType {
-  isVisibleOnFront: string;
-  id: string;
-  code: string;
-  adminName: string;
-  type: string;
-  label?: string;
-}
-
-export type additionalDataTypes = {
-  attribute: AttributeType;
-  id: string;
-  code: string;
-  label: string;
-  value: string | null;
-  admin_name: string;
-  type: string;
-};
-
-// Product review
-
-export interface RatingTypes {
-  length?: number;
-  value?: number;
-  size?: string;
-  className?: string;
-  onChange?: (value: number) => void;
-}
-
-export interface ReviewDatatypes {
-  id: string;
-  name: string;
-  title: string;
-  rating: 5;
-  status: string;
-  comment: string;
-  productId: string;
-  customerId: string;
-  createdAt: string;
-  images: {
-    url: string;
-    reviewId: string;
-  }[];
-  customer: {
-    name: string;
-    imageUrl: string;
-  };
-}
-

+ 1 - 1
src/components/common/AddToCartModal/AddToCartModal.tsx

@@ -17,7 +17,7 @@ import { overlayLoading } from "@/components/theme/ui/kernel/loading/api";
 import type { 
     ProductOption, 
     ResolvedVariant 
-} from "@/components/catalog/type";
+} from "@/types/products/productDetail";
 import CommonModal from "@/components/theme/ui/CommonModal";
 import ProductOptionsInAddToCartModal from "./ProductOptionsInAddToCartModal";
 import { Price } from "@components/theme/ui/Price";

+ 98 - 47
src/components/common/AddToCartModal/OpenAddToCartModalButton.tsx

@@ -1,64 +1,115 @@
 "use client";
-import { type FC, type ReactNode } from "react";
+import { type FC, type ReactNode, useRef } from "react";
+import clsx from "clsx";
 import { useAppDispatch } from "@/store/hooks";
 import { openAddToCartDialog } from "@/store/slices/addToCartDialogSlice";
-import { GET_PRODUCT_BY_ID } from "@/graphql";
+import { 
+    GET_PRODUCT_OPTIONS,
+    GET_PRODUCT_BASE_INFO
+} from "@/graphql";
 import { useLazyQuery } from "@apollo/client/react";
 import { LoadingSpinner } from "@components/common/LoadingSpinner";
-import ShoppingCartIcon from "@components/common/icons/ShoppingCartIcon";
 import { useCustomToast } from "@utils/hooks/useToast";
 import { handleApolloBusinessError } from "@/lib/ApolloErrorHandler";
 interface OpenAddToCartModalButtonProps {
-  // productUrlKey?: string;
-  productId: number;
-  icon?: ReactNode;
+    className?: string;
+    productId: number;
+    children?: ReactNode;
 }
 export const OpenAddToCartModalButton: FC<OpenAddToCartModalButtonProps> = ({
-  // productUrlKey,
-  productId,
-  icon,
+    className,
+    productId,
+    children
 }) => {
-  const dispatch = useAppDispatch();
-  // const [loading, setLoading] = useState(false);
-  const { showToast } = useCustomToast();
+    const dispatch = useAppDispatch();
+    const { showToast } = useCustomToast();
 
-  const [getContent, { loading }] = useLazyQuery(GET_PRODUCT_BY_ID, {
-    fetchPolicy: "network-only",
-  });
-  const openDialog = async () => {
-    if (loading) return;
-    try {
-      const result = await getContent({
-        variables: { id: productId },
-      });
+    // const [getContent, { loading }] = useLazyQuery(GET_PRODUCT_BY_ID, {
+    //     fetchPolicy: "network-only",
+    // });
+    const [getBaseInfo, { loading: baseInfoLoading }] = useLazyQuery(GET_PRODUCT_BASE_INFO, {
+        fetchPolicy: "network-only",
+    });
+    const [getOptions, { loading: optionsLoading }] = useLazyQuery(GET_PRODUCT_OPTIONS, {
+        fetchPolicy: "network-only",
+    });
+    const clickLockRef = useRef(false);
+    const openDialog = async () => {
+        if (clickLockRef.current) return;
+        clickLockRef.current = true;
+        try {
+            const [baseRes, optRes] = await Promise.all([
+                getBaseInfo({ variables: { id: productId }}),
+                getOptions({ variables: { id: productId }})
+            ]);
 
-      console.log("product ----- ", result);
+            if (baseRes.error || optRes.error) {
+                let msg = '';
+                if(baseRes.error) {
+                    msg = baseRes.error.message;
+                } else if(optRes.error) {
+                    msg = optRes.error.message;
+                }
+                showToast(msg, "danger");
+            } else {
+                const baseInfo = baseRes.data?.product;
+                const optData = optRes.data?.product;
+                if(!baseInfo || !optData) {
+                    showToast("Product data is null", "danger");
+                } else {
+                    dispatch(openAddToCartDialog({
+                        ...baseInfo,
+                        ...optData
+                    }));
+                }
+            }
+            /*
+            const result = await getContent({
+                variables: { id: productId },
+            });
 
-      const productData = result?.data?.product;
-      if (result.error) {
-        showToast(result.error.message, "danger");
-      } else {
-        if (productData) {
-          dispatch(openAddToCartDialog(productData));
-        } else {
-          //提示产品数据为空
-          showToast("Product data is null", "danger");
+            console.log("product ----- ", result);
+
+            const productData = result?.data?.product;
+            if (result.error) {
+                showToast(result.error.message, "danger");
+            } else {
+                if (productData) {
+                    dispatch(openAddToCartDialog(productData));
+                } else {
+                    //提示产品数据为空
+                    showToast("Product data is null", "danger");
+                }
+            }
+            */
+        } catch (error) {
+            console.error("error ----- ", error);
+            handleApolloBusinessError(error, (err) => {
+                showToast(err.message, "danger");
+            });
+        } finally {
+            clickLockRef.current = false;
         }
-      }
-    } catch (error) {
-      console.log("error ----- ", error);
-      handleApolloBusinessError(error, (err) => {
-        showToast(err.message, "danger");
-      });
-    }
-  };
-  return (
-    <button onClick={openDialog} disabled={loading}>
-      {loading ? (
-        <LoadingSpinner colorClassName={"text-ly-green"} />
-      ) : (
-        <ShoppingCartIcon icon={icon} />
-      )}
-    </button>
+    };
+    return (
+        <button onClick={openDialog} disabled={baseInfoLoading || optionsLoading}
+            className={clsx(className ? className : "flex justify-center items-center h-8 w-8 bg-ly-middlegreen rounded-lg")}
+        >
+            {baseInfoLoading || optionsLoading ? (
+                <LoadingSpinner colorClassName={"text-ly-green"} />
+            ) : (<>
+                {children ? 
+                    children 
+                : 
+                    <svg xmlns="http://www.w3.org/2000/svg" xmlnsXlink="http://www.w3.org/1999/xlink" width="22.39990234375" height="22.400390625" viewBox="0 0 22.39990234375 22.400390625" fill="none">
+                        <path    stroke="rgba(255, 255, 255, 1)" strokeWidth="1.3333333333333333" strokeLinejoin="round" strokeLinecap="round"  d="M1.40137 2.7998L3.0347 2.7998L3.7347 5.5998M3.7347 5.5998L6.06803 14.9331L18.2014 14.9331L20.5347 5.5998L3.7347 5.5998Z"></path>
+                        <ellipse cx="6.067017078399658" cy="18.199806213378906" rx="1.4000248908996582" ry="1.4000015258789062" stroke="rgba(255, 255, 255, 1)" strokeWidth="1.3333333333333333" strokeLinejoin="round" strokeLinecap="round" ></ellipse>
+                        <ellipse cx="18.2008056640625" cy="18.199806213378906" rx="1.4000244140625" ry="1.4000015258789062" stroke="rgba(255, 255, 255, 1)" strokeWidth="1.3333333333333333" strokeLinejoin="round" strokeLinecap="round" ></ellipse>
+                        <path    stroke="rgba(255, 255, 255, 1)" strokeWidth="1.3333333333333333" strokeLinejoin="round" strokeLinecap="round"  d="M10.2666 10.2666L13.9999 10.2666"></path>
+                        <path    stroke="rgba(255, 255, 255, 1)" strokeWidth="1.3333333333333333" strokeLinejoin="round" strokeLinecap="round"  d="M12.1357 12.1327L12.1357 8.39941"></path>
+                    </svg>
+                }
+            </>)}
+        </button>
   );
 };

+ 1 - 1
src/components/common/AddToCartModal/ProductOptionsInAddToCartModal.tsx

@@ -3,7 +3,7 @@
 import clsx from "clsx";
 import type { 
     ProductOption, 
-} from "@/components/catalog/type";
+} from "@/types/products/productDetail";
 
 export default function ProductOptionsInAddToCartModal({
     productOptions,

+ 9 - 2
src/components/common/NextImage.tsx

@@ -3,7 +3,6 @@
 import { useState } from "react";
 import Image from "next/image";
 import { NOT_IMAGE } from "@/utils/constants";
-import type { NextImageProps } from "./type";
 import { Shimmer } from "./Shimmer";
 
 
@@ -15,7 +14,15 @@ export function NextImage({
   height,
   sizes,
   priority = false,
-}: NextImageProps) {
+}: {
+  src: string;
+  alt: string;
+  className?: string;
+  width: number;
+  height: number;
+  sizes?: string;
+  priority?: boolean;
+}) {
   const [isLoaded, setIsLoaded] = useState(false);
   const [hasError, setHasError] = useState(false);
 

+ 9 - 2
src/components/common/Rating.tsx

@@ -1,5 +1,4 @@
 import clsx from "clsx";
-import type { RatingTypes } from "./type";
 import StarIcon from "./icons/StarIcon";
 
 export const Rating = ({
@@ -9,7 +8,15 @@ export const Rating = ({
   className,
   reviewCount,
   onReviewClick,
-}: RatingTypes) => {
+}: {
+  length?: number;
+  star: number;
+  reviewCount?: number;
+  size?: string;
+  className?: string;
+  totalReview?: number;
+  onReviewClick?: () => void;
+}) => {
   const rating = star ?? 0;
   const reviewCountToShow = reviewCount ?? star > 0;
 

+ 0 - 34
src/components/common/icons/ShoppingCartIcon.tsx

@@ -1,34 +0,0 @@
-import { type ComponentProps, type ReactNode } from "react";
-import clsx from "clsx";
-
-interface ShoppingCartIconProps extends ComponentProps<"svg"> {
-  icon?: ReactNode;
-}
-
-export default function ShoppingCartIcon({
-  className,
-  icon,
-}: ShoppingCartIconProps) {
-  // 如果传了 icon → 直接返回自定义图标
-  if (icon) {
-    return <>{icon}</>;
-  }
-
-  // 没传 → 返回默认购物车图标
-  return (
-    <svg
-      className={clsx("fill-primary h-5 w-5 dark:fill-transparent", className)}
-      fill="none"
-      stroke="currentColor"
-      strokeWidth="1.5"
-      viewBox="0 0 24 24"
-      xmlns="http://www.w3.org/2000/svg"
-    >
-      <path
-        d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z"
-        strokeLinecap="round"
-        strokeLinejoin="round"
-      />
-    </svg>
-  );
-}

+ 0 - 20
src/components/common/type.ts

@@ -1,20 +0,0 @@
-export interface NextImageProps {
-  src: string;
-  alt: string;
-  className?: string;
-  width: number;
-  height: number;
-  sizes?: string;
-  priority?: boolean;
-}
-
-
-export interface RatingTypes {
-  length?: number;
-  star: number;
-  reviewCount?: number;
-  size?: string;
-  className?: string;
-  totalReview?: number;
-  onReviewClick?: () => void;
-}

+ 0 - 59
src/components/customer/accountSwiper.tsx

@@ -236,65 +236,6 @@ const AccountSwiper = () => {
                       productId={
                         item.id
                       }
-                      icon={
-                        <svg
-                          xmlns="http://www.w3.org/2000/svg"
-                          width="32"
-                          height="32"
-                          viewBox="0 0 32 32"
-                          fill="none"
-                        >
-                          <rect
-                            x="0"
-                            y="0"
-                            width="32"
-                            height="32"
-                            rx="8"
-                            fill="#036141"
-                          ></rect>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M6.40039 7.79993L8.03372 7.79993L8.73372 10.5999M8.73372 10.5999L11.0671 19.9333L23.2004 19.9333L25.5337 10.5999L8.73372 10.5999Z"
-                          ></path>
-                          <ellipse
-                            cx="11.066040515899658"
-                            cy="23.199928283691406"
-                            rx="1.4000248908996582"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <ellipse
-                            cx="23.1998291015625"
-                            cy="23.199928283691406"
-                            rx="1.4000244140625"
-                            ry="1.4000015258789062"
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                          ></ellipse>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M15.2656 15.2665L18.999 15.2665"
-                          ></path>
-                          <path
-                            stroke="rgba(255, 255, 255, 1)"
-                            stroke-width="1.3333333333333333"
-                            stroke-linejoin="round"
-                            stroke-linecap="round"
-                            d="M17.1348 17.1332L17.1348 13.3999"
-                          ></path>
-                        </svg>
-                      }
                     />
                   </div>
                 </div>

+ 0 - 53
src/components/home/ProductCarouselTheme.tsx

@@ -1,53 +0,0 @@
-import { ProductCard } from "@components/catalog/product/ProductCard";
-import type { ProductsSectionProps } from "@components/catalog/type";
-import { getImageUrl } from "@utils/constants";
-
-const Theme = ({ title, description, products }: ProductsSectionProps) => {
-  return (
-    <section className="pt-8 sm:pt-12 lg:pt-20">
-      <div className="md:max-w-4.5xl mx-auto mb-6 w-full px-0 text-center xss:mb-10 md:px-36">
-        <h2 className="mb-4 font-outfit text-xl md:text-4xl font-semibold text-black dark:text-white">
-          {title}
-        </h2>
-        <p className="text-sm md:text-base font-normal text-black/60 dark:text-neutral-300">
-          {description}
-        </p>
-      </div>
-
-      <div className="w-full pb-6 pt-1">
-        <ul className="m-0 grid grid-cols-2 justify-center gap-5 lg:gap-11.5 p-0 xss:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
-          {products.map((item, index) => {
-            const imageUrl = getImageUrl(
-              item?.baseImageUrl
-            );
-            const ProductPrice =
-              item?.type === "configurable"
-                ? (item?.minimumPrice ?? "0")
-                : (item?.price ?? "0");
-
-            return (
-              <ProductCard
-                key={item.id ?? index}
-                currency="USD"
-                imageUrl={imageUrl || ""}
-                price={String(ProductPrice)}
-                product={{
-                  _id: item._id,
-                  urlKey: item.urlKey || item.sku,
-                  name: item?.name || item.sku,
-                  id: item.id,
-                  type: item.type,
-                  isSaleable: item.isSaleable,
-                }}
-                specialPrice={""}
-                priority={index < 4}
-              />
-            );
-          })}
-        </ul>
-      </div>
-    </section>
-  );
-};
-
-export default Theme;

+ 1 - 1
src/components/theme/filters/NewMobileFilter.tsx

@@ -14,7 +14,7 @@ import {
   XMarkIcon,
 } from "@heroicons/react/24/outline";
 import { useEffect, useState, useRef, use } from "react";
-import type{ GetCategoryAttrFiltersResult } from "@components/catalog/type";
+import type{ GetCategoryAttrFiltersResult } from "@/types/category/type";
 
 
 interface MobileFilterProps {

+ 0 - 63
src/graphql/catalog/fragments/ProductDetailed.ts

@@ -1,63 +0,0 @@
-import { gql } from "@apollo/client";
-
-export const PRODUCT_DETAILED_FRAGMENT = gql`
-  fragment ProductDetailed on Product {
-    id
-    _id
-    sku
-    type
-    name
-    urlKey
-    description
-    shortDescription
-    feature
-    price
-    baseImageUrl
-    minimumPrice
-    specialPrice
-    isSaleable
-    images {
-      edges{
-        node {
-          id
-          path
-          publicPath
-          position
-        }
-      }
-    }
-    productOptions
-    flexibleVariants {
-      id
-      _id
-      sku
-      variantImages
-      optionValues
-      quantity
-      price
-      priceIndices
-    }
-    variants {
-      edges {
-        node {
-          id
-          sku
-          baseImageUrl
-        }
-      }
-    }
-    reviews {
-      edges {
-        node {
-          id
-          _id
-          rating
-          title
-          name
-          comment
-          createdAt
-        }
-      }
-    }
-  }
-`;

+ 1 - 1
src/graphql/catalog/fragments/index.ts

@@ -1,2 +1,2 @@
 export { PRODUCT_CORE_FRAGMENT } from "./ProductCore";
-export { PRODUCT_DETAILED_FRAGMENT } from "./ProductDetailed";
+

+ 39 - 0
src/graphql/catalog/queries/GetProductBaseInfo.ts

@@ -0,0 +1,39 @@
+import { gql,type TypedDocumentNode } from "@apollo/client";
+import type { ProductBaseInfo } from "@/types/products/productDetail";
+
+export const GET_PRODUCT_BASE_INFO: TypedDocumentNode<{product: ProductBaseInfo}> = gql`
+    query GetProductBaseInfo(
+        $urlKey: String
+        $id: ID
+    ) {
+        product(
+            urlKey: $urlKey
+            id: $id
+        ) {
+            id
+            _id
+            sku
+            type
+            name
+            urlKey
+            description
+            shortDescription
+            feature
+            price
+            baseImageUrl
+            minimumPrice
+            specialPrice
+            isSaleable
+            images {
+                edges{
+                    node {
+                        id
+                        path
+                        publicPath
+                        position
+                    }
+                }
+            }
+        }
+    }
+`;

+ 42 - 9
src/graphql/catalog/queries/GetProductById.ts

@@ -1,15 +1,48 @@
 import { gql,type TypedDocumentNode } from "@apollo/client";
-import { PRODUCT_DETAILED_FRAGMENT } from "../fragments";
-import type { SingleProductResponse } from "@/components/catalog/type";
+import type { GetProductByIdResponse } from "@/types/products/productDetail";
 /**
+ * 这个接口用GET_PRODUCT_BASE_INFO和GET_PRODUCT_OPTIONS 这两个接口代替
  * Fetch a single product by id with all details
+ * used in add to cart modal
  */
-export const GET_PRODUCT_BY_ID: TypedDocumentNode<SingleProductResponse> = gql`
-  ${PRODUCT_DETAILED_FRAGMENT}
-
-  query GetProductById($id: ID!) {
-    product(id: $id) {
-      ...ProductDetailed
+export const GET_PRODUCT_BY_ID: TypedDocumentNode<GetProductByIdResponse> = gql`
+    query GetProductById($id: ID!) {
+        product(id: $id) {
+            id
+            _id
+            sku
+            type
+            name
+            urlKey
+            description
+            shortDescription
+            feature
+            price
+            baseImageUrl
+            minimumPrice
+            specialPrice
+            isSaleable
+            images {
+                edges{
+                    node {
+                        id
+                        path
+                        publicPath
+                        position
+                    }
+                }
+            }
+            productOptions
+            flexibleVariants {
+                id
+                _id
+                sku
+                variantImages
+                optionValues
+                quantity
+                price
+                priceIndices
+            }
+        }
     }
-  }
 `;

+ 0 - 16
src/graphql/catalog/queries/GetProductByUrlKey.ts

@@ -1,16 +0,0 @@
-import { gql,type TypedDocumentNode } from "@apollo/client";
-import { PRODUCT_DETAILED_FRAGMENT } from "../fragments";
-import type { SingleProductResponse } from "@/components/catalog/type";
-/**
- * Fetch a single product by URL key with all details
- * @param urlKey - Product URL key
- */
-export const GET_PRODUCT_BY_URL_KEY: TypedDocumentNode<SingleProductResponse> = gql`
-  ${PRODUCT_DETAILED_FRAGMENT}
-
-  query GetProductById($urlKey: String!) {
-    product(urlKey: $urlKey) {
-      ...ProductDetailed
-    }
-  }
-`;

+ 28 - 0
src/graphql/catalog/queries/GetProductOptions.ts

@@ -0,0 +1,28 @@
+import { gql,type TypedDocumentNode } from "@apollo/client";
+import type { GetProductOptionsResponse } from "@/types/products/productDetail";
+/**
+ * Fetch a single product's options and flexible variants by id
+ */
+export const GET_PRODUCT_OPTIONS: TypedDocumentNode<GetProductOptionsResponse> = gql`
+    query GetProductDetail(
+        $id: ID
+        $urlKey: String
+    ) {
+        product(
+            id: $id
+            urlKey: $urlKey
+        ) {
+            productOptions
+            flexibleVariants {
+                id
+                _id
+                sku
+                variantImages
+                optionValues
+                quantity
+                price
+                priceIndices
+            }
+        }
+    }
+`;

+ 0 - 2
src/graphql/catalog/queries/GetRelatedProducts.ts

@@ -11,8 +11,6 @@ export const GET_RELATED_PRODUCTS = gql`
 
   query GetRelatedProducts($urlKey: String, $first: Int) {
     product(urlKey: $urlKey) {
-      id
-      sku
       relatedProducts(first: $first) {
         edges {
           node {

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

@@ -1,6 +1,6 @@
 import { gql,type TypedDocumentNode } from "@apollo/client";
 import { PRODUCT_CORE_FRAGMENT } from "../fragments";
-import type { SearchProductsResponse,SearchProductsVariables } from "@/components/catalog/type";
+import type { SearchProductsResponse,SearchProductsVariables } from "@/types/category/type";
 
 /**
  * SearchProducts 支持两种分页,一种是游标分页;一种是页码分页

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

@@ -1,5 +1,4 @@
 export { GET_PRODUCTS } from "./GetProducts";
-export { GET_PRODUCT_BY_URL_KEY } from "./GetProductByUrlKey";
 export { GET_PRODUCTS_PAGINATION } from "./GetProductsPagination";
 export { GET_RELATED_PRODUCTS } from "./GetRelatedProducts";
 export { GET_PRODUCT_SWATCH_REVIEW } from "./GetProductSwatchReview";
@@ -12,5 +11,7 @@ export { SEARCH_PRODUCTS } from "./SearchProducts";
 export { GET_CATEHORY_BY_ID } from "./GetCategoryById";
 export { GET_CATEHORY_BY_SLUG } from "./GetCategoryBySlug";
 export { GET_PRODUCT_BY_ID } from "./GetProductById";
+export { GET_PRODUCT_BASE_INFO } from "./GetProductBaseInfo";
+export { GET_PRODUCT_OPTIONS } from  "./GetProductOptions";
 export { GET_CATEGORY_ATTR_FILTERS } from "./CategoryAttributeFilters";
 export { CATEGORY_PRODUCTS } from "./GetCategoryProducts";

+ 3 - 3
src/store/slices/addToCartDialogSlice.ts

@@ -1,9 +1,9 @@
 import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
-import type { ProductNode } from "@/components/catalog/type";
+import type { ProductBaseInfo, ProductOptionsData } from "@/types/products/productDetail";
 
 interface DialogState {
   isOpen: boolean;
-  product: ProductNode | null;
+  product: ProductBaseInfo & ProductOptionsData | null;
 }
 
 const initialState: DialogState = {
@@ -15,7 +15,7 @@ const addToCartDialogSlice = createSlice({
   name: 'addToCartDialog',
   initialState,
   reducers: {
-    openAddToCartDialog(state, action: PayloadAction<ProductNode>) {
+    openAddToCartDialog(state, action: PayloadAction<ProductBaseInfo & ProductOptionsData>) {
       state.isOpen = true;
       state.product = action.payload;
     },

+ 90 - 179
src/types/category/type.ts

@@ -1,196 +1,107 @@
-// Category List Types 
-
-
-// Category Details Types 
-
-export interface ProductOptionNode {
-  id: string;
-  adminName: string;
-  isValid?: boolean; 
-}
-
-
-export interface ProductOptionEdge {
-  node: ProductOptionNode;
+import type { ProductCoreNode } from "@/types/products/productDetail";
+
+export interface SearchProductsVariables {
+    query: string;
+    suggest?: boolean;
+    filter?: string;
+    sortKey?: string;
+    reverse?: boolean;
+    page?: number;
+    first?: number;
+    last?: number;
+    after?: string;
+    before?: string;
+    channel?: string;
+    locale?: string;
+}
+export interface SearchProductsResponse {
+  search: {
+    edges: Array<{ node: ProductCoreNode }>;
+    pageInfo: {
+      endCursor: string;
+      startCursor: string;
+      hasNextPage: boolean;
+      hasPreviousPage: boolean;
+      currentPage: number;
+      totalPages: number;
+    };
+    totalCount: number;
+
+    searchMetadata: {
+      originalQuery: string;
+      effectiveQuery: string;
+      resultMessage: string;
+      searchInsteadMessage: string;
+    }
+  };
 }
 
 
-export interface AttributeOptions {
-  edges: ProductOptionEdge[];
-}
 
-
-export interface ProductAttribute {
-  id: string;
+export interface CategoryAttrFilterItem  {
+  _id: number;
   code: string;
   adminName: string;
-  options: AttributeOptions;
-}
-export type AttributeData = ProductAttribute;
-
-
-export interface ProductReview {
-  id: string;
-  rating: number;
-  name: string;
-  title: string;
-  comment: string;
-}
-
-export interface ProductReviewEdge {
-  node: ProductReview;
-}
-
-
-
-
-
-// Product Swatch Review Types
-export interface SuperAttributeOption {
-  id: string;
-  code: string;
-  label?: string;
-  adminName?: string;
-  type?: string;
-  swatchType?: string;
-  swatchValue?: string;
-  options?: Array<{
-    id: string;
-    label?: string;
-    swatchValue?: string;
-    products?: number[];
-  }>;
-}
-
-export interface AttributeValueNode {
-  id: string;
-  code: string;
-  label?: string;
-  value?: string | null;
-  attribute?: {
-    id: string;
-    code: string;
-    adminName?: string;
-    type?: string;
-  };
-}
-
-export interface ProductSwatchReview {
-  id: string;
   type: string;
-  isSaleable?: string;
-  superAttributeOptions?: string; // JSON string
-  combinations?: string; // JSON string
-  superAttributes?: {
+  swatchType: string | null;
+  position: number | null;
+  minPrice: string;
+  maxPrice: string;
+  options: {
     edges: Array<{
-      node: SuperAttributeOption;
+      node: {
+        _id: string;
+        adminName: string;
+        sortOrder: number;
+        swatchValue: string | null;
+        productCount:number;
+      };
     }>;
   };
-  attributeValues?: {
-    edges: Array<{
-      node: AttributeValueNode;
-    }>;
-  };
-}
-
-
-
-// GraphQL Response Types
-
-export interface ProductEdge {
-  node: {
-    id: string;
-    name: string;
-    urlKey: string;
-    type: string;
-    sku: string;
-    baseImageUrl?: string;
-    minimumPrice?: string;
-    specialPrice?: string;
-    priceHtml?: {
-      regularPrice: string;
-      finalPrice: string;
-      currencyCode: string;
-    };
-  };
-}
-
-export interface ProductsResponse {
-  products: {
-    edges: ProductEdge[];
-    pageInfo?: {
+};
+export interface GetCategoryAttrFiltersResult  {
+  categoryAttributeFilters: {
+    edges: Array<{ node: CategoryAttrFilterItem }>;
+    pageInfo: {
       hasNextPage: boolean;
-      hasPreviousPage: boolean;
-      startCursor?: string;
-      endCursor?: string;
-    };
-    totalCount?: number;
-  };
-}
-
-export interface CategoryEdge {
-  node: {
-    id: string;
-    position?: number;
-    logoUrl?: string;
-    translation: {
-      name: string;
-      slug: string;
-      description?: string;
+      endCursor: string | null;
     };
   };
-}
-
-export interface CategoriesResponse {
-  categories: {
-    edges: CategoryEdge[];
-  };
-}
-
-export interface RelatedProductsResponse {
-  relatedProducts: {
-    edges: ProductEdge[];
-  };
-}
-
-
-// Review Types
-
-export interface ReviewInput {
-  productId: number;
-  title: string;
-  comment: string;
-  rating: number;
-  name: string;
-  email: string;
-  attachments?: string;
-}
-
-export interface ReviewFieldInputs {
-  input: ReviewInput;
-}
-
-export interface ProductReview {
+};
+export type CategoryProductNode = {
+  _id: number;
   id: string;
+  sku: string;
   name: string;
-  title: string;
-  rating: number;
-  comment: string;
+  type: string;
+  price: string;
+  minimumPrice: string;
+  maximumPrice: string;
+  formattedPrice: string;
+  baseImageUrl: string;
   status: string;
-}
-
-export interface CreateProductReviewData {
-  createProductReview: {
-    success: boolean;
-    review: ProductReview;
+  new: string;
+  featured: string;
+  urlKey: string;
+  isInWishlist: string;
+};
+
+export type CategoryProductsResult = {
+  categoryProducts: {
+    totalCount: number;
+    description: string;
+    banner: string;
+    pageInfo:{
+      endCursor:string,
+      hasNextPage: boolean
+    }
+    products: Array<{
+      cursor: string;
+      node: CategoryProductNode;
+    }>;
   };
-}
-
-export interface CreateProductReviewVariables {
-  input: ReviewInput;
-}
-
-export interface CreateProductReviewOperation {
-  data: CreateProductReviewData;
-  variables: CreateProductReviewVariables;
-}
+};
+export type PageInfo = {
+  endCursor: string;
+  hasNextPage: boolean;
+};

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

@@ -1,20 +1,139 @@
-/**
- * 单条评论主体 Node 结构 ProductReview
- */
-export interface ProductReviewNode {
-  name: string; // 评论人昵称
-  title: string; // 标题/发长
-  rating: number; // 星级 1-5
-  comment: string; // 评论正文
-  createdAt: string; // ISO时间字符串
-  __typename: "ProductReview";
-}
-export interface ProductReviewEdge {
-  node: ProductReviewNode;
-  __typename: "ProductReviewEdge";
-}
-
-/**
- * 整体:评论数组 = ProductReviewEdge[]
- */
-export type ProductReviewList = ProductReviewEdge[];
+export interface ProductOptionValue {
+  code: string;
+  id: number;
+  label: string;
+  meta: [] | Record<string, unknown>;  // Record<string, unknown>也可以写成 { [key: string]: unknown },表示任意对象,unknow比any更安全
+  position: number;
+}
+
+export interface ProductOption {
+  code: string;
+  id: number;
+  is_required: boolean;
+  label: string;
+  meta: [] | Record<string, unknown>;
+  position: number;
+  type: string;
+  values: Array<ProductOptionValue>;
+}
+
+export interface ProductFlexibleVariantValue {
+    code: string;
+    id: number;
+    label: string;
+    option_code: string;
+    option_id: number;
+}
+
+export interface ProductFlexibleVariantPriceIndices{
+  id: number;
+  min_price: string;
+  regular_min_price:string;
+}
+
+export interface VatiantImages {
+  id: number;
+  position: number;
+  url: string;
+}
+
+export interface ProductFlexibleVariant {
+  id: string;
+  _id: number;
+  sku: string;
+  variantImages: string | null | VatiantImages[]; 
+  optionValues: string | Array<ProductFlexibleVariantValue>
+  quantity:number;
+  price: string;
+  priceIndices: string | null | Array<ProductFlexibleVariantPriceIndices>;
+}
+
+export interface ProductMediaType{
+  id: string;
+  path: string;
+  publicPath: string;
+  position: string;
+}
+
+// 定义解析后的类型(根据最初类型裁剪)
+export type ResolvedVariant = Omit<ProductFlexibleVariant, 'optionValues' | 'variantImages | priceIndices'> & {
+  optionValues: ProductFlexibleVariantValue[];
+  variantImages: VatiantImages[]; // 去掉 string 可能性
+  priceIndices: ProductFlexibleVariantPriceIndices[];
+};
+
+export interface ProductCoreNode {
+  id: string;
+  _id: number;
+  sku: string;
+  type: string;
+  name: string;
+  price: string;
+  formattedPrice: string;
+  formattedSpecialPrice: string | null;
+  formattedMinimumPrice: string;
+  urlKey: string;
+  baseImageUrl: string;
+  minimumPrice: string;
+  specialPrice: string | null;
+  isSaleable: string | undefined;
+  // images?: {
+  //   edges: Array<{
+  //     node: {
+  //       publicPath: string;
+  //     };
+  //   }>;
+  // };
+}
+
+
+export interface ProductBaseInfo {
+    id: string;
+    _id: number;
+    sku: string;
+    type: string;
+    name: string;
+    urlKey: string;
+    description: string;
+    shortDescription: string;
+    feature: string;
+    price: string;
+    baseImageUrl: string;
+    minimumPrice: string;
+    specialPrice: string | null;
+    isSaleable: string | undefined;
+    metaTitle?: string;
+    metaKeywords?: string;
+    metaDescription?: string;
+    images: {
+        edges: Array<{ 
+            node: ProductMediaType
+        }>;
+    }
+}
+
+export interface ProductOptionsData {
+  productOptions: string | null;
+  flexibleVariants: Array<ProductFlexibleVariant>;
+}
+
+export interface GetRelatedProductsResponse {
+  product: {
+    relatedProducts: {
+        edges: Array<{ node: ProductCoreNode }>;
+      };
+  }
+}
+export interface GetProductBaseInfoResponse {
+    product: ProductBaseInfo;
+}
+
+export type GetProductByIdDetail  = ProductBaseInfo & ProductOptionsData;
+export interface GetProductByIdResponse  {
+    product: GetProductByIdDetail | null;
+}
+
+
+export interface GetProductOptionsResponse {
+    product: ProductOptionsData;
+}

+ 19 - 8
src/types/products/review.ts

@@ -4,21 +4,32 @@ export interface ProductReviewNode {
   name: string;
   title: string;
   rating: number;
+  like_count: number;
   comment: string;
+  status: string;
   created_at: string;
   updated_at: string;
-  attachments: string[]; // 评论图片数组,空=无图
   migrated_from_asteria_id:number;
-  isVip: boolean;
-  tag: "influencer" | "verified" | "";
-  like_count: number;
+  liked: boolean;
+  attachments: Array<{
+    type: string;
+    url: string;       
+  }>; // 评论图片数组,空=无图
 }
 
-export interface ProductReviewEdge {
-  node: ProductReviewNode;
-  __typename: "ProductReviewEdge";
+export interface ProductReviewsRestfulResponse {
+  data: ProductReviewNode[];
+  message: string;
+  pagination: {
+    current_page: number;
+    per_page: number;
+    total: number; 
+    last_page: number;
+    has_more: boolean;
+  };
+  success: boolean;
 }
-export type ProductReviewList = ProductReviewEdge[];
+
 
 // 图库弹窗一维平铺图片项
 export interface GalleryItem {

+ 0 - 345
src/types/types.ts

@@ -11,19 +11,6 @@ export interface LogoutData {
 }
 
 
-export type Maybe<T> = T | null;
-
-export type Connection<T> = {
-  edges: Array<Edge<T>>;
-};
-
-export type Edge<T> = {
-  node: T;
-};
-
-
-
-
 /**
  * Sorting & filtration constants
  */
@@ -69,290 +56,6 @@ export type getFilterAttributeTypes = {
 
 
 
-export type selectedPaymentMethodType = {
-  id: string;
-  method: string;
-  methodTitle: string;
-  cartId: string;
-};
-
-
-export type Image = {
-  url: string;
-  altText: string;
-  width: number;
-  height: number;
-};
-
-export type ImageInfo = {
-  url: string;
-  type: string;
-  path: string;
-  productId: number;
-  altText: string;
-};
-export type Product = Omit<BagistoProductInfo, "variants" | "images"> & {
-  products: ProductDetailsInfo[];
-  paginatorInfo: {
-    count: number;
-    currentPage: number;
-    lastPage: number;
-    total: number;
-  };
-};
-
-export type ProductDetailsInfo = Omit<
-  BagistoProductInfo,
-  "variants" | "images"
-> & {
-  variants: ProductVariant[];
-  images: ImageInfo[];
-};
-
-export type ProductOption = {
-  id: string;
-  name: string;
-  // values: string[];
-  displayName: string;
-  values: ProductOptionValues[];
-};
-
-export type ProductOptionValues = {
-  label: string;
-  hexColors?: string[];
-};
-
-export type ProductVariant = {
-  id: string;
-  label: string;
-  sku: string;
-  availableForSale: boolean;
-  options: {
-    id: string;
-    label: string;
-    products: [number];
-  }[];
-  selectedOptions: {
-    name: string;
-    value: string;
-  }[];
-  price: string;
-};
-
-export type SEO = {
-  title: string;
-  description: string;
-};
-
-
-
-export type BagistoCollection = {
-  handle: string;
-  title: string;
-  description: string;
-  seo: SEO;
-  urlPath?: string;
-  updatedAt: string;
-  id: string;
-  logoPath?: string;
-  logoUrl?: string;
-  name: string;
-  slug: string;
-};
-
-export type BagistoCollectionMenus = {
-  id: string;
-  logoPath?: string;
-  logoUrl?: string;
-  name: string;
-  slug: string;
-};
-
-export type BagistoProduct = {
-  name: string;
-  longDescription?: string;
-  urlKey: string;
-  slug?: string;
-  path?: string;
-  price: ProductPrice;
-  id: string;
-  handle: string;
-  availableForSale: boolean;
-  title: string;
-  description: string;
-  descriptionHtml: string;
-  options: ProductOption[];
-  variants: Connection<ProductVariant>;
-  featuredImage: Image;
-  images: Connection<Image>;
-  seo: SEO;
-  tags: string[];
-  updatedAt: string;
-};
-
-export type BagistoProductInfo = {
-  name: string;
-  longDescription?: string;
-  urlKey: string;
-  type: string;
-  status: boolean;
-  slug?: string;
-  width: string;
-  height: string;
-  path?: string;
-  metaTitle: string;
-  metaDescription: string;
-  price: ProductPrice;
-  id: string;
-  _id: number;
-  handle: string;
-  availableForSale: boolean;
-  title: string;
-  description: string;
-  shortDescription: string;
-  descriptionHtml: string;
-  options: ProductOption[];
-  cacheGalleryImages: {
-    smallImageUrl: string;
-    mediumImageUrl: string;
-    largeImageUrl: string;
-    originalImageUrl: string;
-  }[];
-  priceHtml: {
-    regularPrice: string;
-    currencyCode: string;
-    formattedRegularPrice: string;
-    finalPrice: string;
-    formattedFinalPrice: string;
-  };
-  variants: Array<ProductVariant>;
-  featuredImage: Image;
-  images: Array<ImageInfo>;
-  productFlats: {
-    id: string;
-    name: string;
-    description: string;
-    metaTitle: string;
-    metaDescription: string;
-    width: string;
-    height: string;
-  }[];
-  relatedProducts: RelatedProducts[];
-  inventories: {
-    qty: string;
-  }[];
-  configutableData: {
-    attributes: ConfigurableProductData[];
-    index: ConfigurableProductIndexData[];
-  };
-  seo: SEO;
-  tags: string[];
-  updatedAt: string;
-};
-
-export type ConfigurableProductIndexData = {
-  id: string;
-  attributeOptionIds: {
-    attributeId: string;
-    attributeCode: string;
-    attributeOptionId: string;
-  }[];
-};
-export type RealatedImageArray = {
-  url: string;
-};
-
-export type RelatedProducts = {
-  id: string;
-  name: string;
-  urlKey: string;
-  type: string;
-  priceHtml: {
-    regularPrice: string;
-    currencyCode: string;
-    finalPrice: string;
-  };
-  images?: RealatedImageArray[];
-  cacheGalleryImages: {
-    smallImageUrl: string;
-    mediumImageUrl: string;
-    largeImageUrl: string;
-    originalImageUrl: string;
-  }[];
-};
-
-export type ConfigurableProductData = {
-  id: string;
-  code: string;
-  label: string;
-  availableForSale: boolean;
-  options: {
-    id: string;
-    code: string;
-    label: string;
-    swatchType: string;
-    swatchValue: string;
-    products: [number];
-  }[];
-};
-
-export type AttributeOptionNode = {
-  id: string;
-  adminName?: string;
-  label?: string;
-  isValid: boolean;
-};
-
-export type AttributeOptionEdge = {
-  node: AttributeOptionNode;
-};
-
-export type AttributeData = {
-  id: string;
-  code: string;
-  label?: string;
-  options: AttributeOptionNode[] | {
-    edges: AttributeOptionEdge[];
-  };
-};
-
-export type ProductPrice = {
-  value: number;
-  currencyCode?: "USD" | "EUR" | "ARS" | string;
-  retailPrice?: number;
-  salePrice?: number;
-  listPrice?: number;
-  extendedSalePrice?: number;
-  extendedListPrice?: number;
-};
-
-
-
-
-export type AddressDataTypes = {
-  id: string;
-  firstName: string;
-  lastName: string;
-  companyName: string;
-  address: string;
-  city: string;
-  state: string;
-  countryName: string;
-  postcode: string;
-  email: string;
-  country: string;
-  addressType: string;
-  vatId: string;
-  phone: string;
-  stateName: string;
-  defaultAddress: boolean;
-  useForShipping: boolean;
-};
-
-
-
-
-
 export type CreateUserVariables = {
   input: {
       firstName: string;
@@ -412,19 +115,6 @@ export type BagistoUser = {
 
 
 
-export type SuperAttribute = {
-  attributeId: number;
-  attributeOptionId: number;
-};
-
-
-export type TranslationsTypes = {
-  id: string;
-  themeCustomizationId: number;
-  localeCode: string;
-  type: string;
-  options: OptionDataTypes;
-};
 
 export type FilterDataTypes = {
   key: string;
@@ -432,41 +122,6 @@ export type FilterDataTypes = {
   __typename: string;
 };
 
-export type ThemeOptions = {
-  url: string;
-  title: string;
-  sortOrder: string;
-};
-export type OptionDataTypes = {
-  title: string;
-  css: string;
-  html: string;
-  images: ImagesDataType[];
-  filters: FilterDataTypes[];
-  column_1: ThemeOptions[];
-  column_2: ThemeOptions[];
-  column_3: ThemeOptions[];
-  services: {
-    service_icon: string;
-    description: string;
-    title: string;
-  }[];
-};
-
-export type ImagesDataType = {
-  title: string;
-  link: string;
-  image: string;
-  imageUrl: string;
-};
-
-
-
-export type InputData = {
-  key: string;
-  value: string | number;
-};
-
 
 export type IconSvgProps = SVGProps<SVGSVGElement> & {
   size?: number;

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

@@ -2,7 +2,7 @@
 import { useState, useRef } from "react";
 import { useApolloClient } from "@apollo/client/react";
 import { SEARCH_PRODUCTS } from "@/graphql";
-import type { SearchProductsResponse, SearchProductsVariables } from "@/components/catalog/type";
+import type { SearchProductsResponse, SearchProductsVariables } from "@/types/category/type";
 
 interface SearchProductResult {
   data: SearchProductsResponse | null;

+ 43 - 2
src/utils/variantTools.ts

@@ -1,3 +1,4 @@
+import Big from 'big.js';
 import type { 
     ProductOption, 
     VatiantImages,
@@ -5,7 +6,7 @@ import type {
     ProductFlexibleVariant,
     ProductFlexibleVariantValue,
     ProductFlexibleVariantPriceIndices
-} from "@/components/catalog/type";
+} from "@/types/products/productDetail";
 
 
 /**
@@ -189,4 +190,44 @@ export function formatFlexibleVariants(originData: ProductFlexibleVariant[] | un
 // 获取可以购买的变体
 export function getAvailableVariants(flexibleVariants: ResolvedVariant[]) {
     return flexibleVariants.filter((variant) => variant.quantity > 0);
-}
+}
+
+
+export function calculateSelectedVariantInfo(selected: Record<number, number>, flexibleVariants: ResolvedVariant[], productQty: number) {
+    const selectedIds = Object.values(selected);
+    let totalLinePrice = 0;
+    let totalNowPrice = 0;
+    let save = 0;
+
+    const variant = flexibleVariants.find((v) => {
+        const vIds = v.optionValues.map((ov) => ov.id);
+        return (
+            selectedIds.length === vIds.length &&
+            selectedIds.every((id) => vIds.includes(id))
+        );
+    });
+    if (selectedIds.length === 0 || variant === undefined) {
+        // 尚未初始化,返回一个安全占位对象
+        return {
+            variant: null,
+            totalLinePrice: 0,
+            totalNowPrice: 0,
+            save: 0,
+        };
+    }
+
+    if (variant.priceIndices && variant.priceIndices.length > 0) {
+        totalLinePrice = Big(variant.priceIndices[0].regular_min_price).times(productQty).toNumber();
+        totalNowPrice = Big(variant.priceIndices[0].min_price).times(productQty).toNumber();
+    }
+    if (totalLinePrice !== totalNowPrice) {
+        save = Big(totalNowPrice).minus(totalLinePrice).toNumber();
+    }
+
+    return {
+        variant,
+        totalLinePrice: totalLinePrice,
+        totalNowPrice: totalNowPrice,
+        save: save,
+    };
+};