Просмотр исходного кода

产品详情页修改 --- 产品选项,变体展示;

fogwind 1 неделя назад
Родитель
Сommit
2395239a2d

+ 78 - 81
src/app/(public)/product/[...urlProduct]/page.tsx

@@ -1,48 +1,37 @@
 import { notFound } from "next/navigation";
 import { Suspense } from "react";
+// import clsx from "clsx";
+
 import {
   ProductDetailSkeleton,
   RelatedProductSkeleton,
 } from "@/components/common/skeleton/ProductSkeleton";
 import {
   BASE_SCHEMA_URL,
-  baseUrl,
-  getImageUrl,
-  NOT_IMAGE,
   PRODUCT_TYPE,
 } from "@/utils/constants";
-import HeroCarousel from "@/components/common/slider/HeroCarousel";
 import { GET_PRODUCT_BY_URL_KEY } from "@/graphql";
-import { isArray } from "@/utils/type-guards";
 import { cachedProductRequest } from "@/utils/hooks/useCache";
 import {
-  ProductNode,
-  ProductVariantNode,
-  ProductData,
-} from "@/components/catalog/type";
+  SingleProductResponse,
+  ProductMediaType,
+  ProductFlexibleVariant,
+  ResolvedVariant
+} from "@components/catalog/type";
 import { RelatedProductsSection } from "@components/catalog/product/RelatedProductsSection";
-import ProductInfo from "@components/catalog/product/ProductInfo";
-import { LRUCache } from "@/utils/LRUCache";
-import { MobileSearchBar } from "@components/layout/navbar/MobileSearch";
 import { HeroCarouselShimmer } from "@components/common/slider";
+import {ProductMedia} from "@/app/(public)/product/_components/ProductMedia";
+import { ProductInformation } from "@/app/(public)/product/_components/ProductInformation";
+import { ProductShortDescription } from "@/app/(public)/product/_components/ProductShortDescription";
+import { ProductDetail } from "@/app/(public)/product/_components/ProductDetail";
+import { ProductReviewSection } from "@/app/(public)/product/_components/ProductReviewSection";
 
-const productCache = new LRUCache<ProductNode>(100, 10);
-export const dynamic = "force-static";
-
-export interface SingleProductResponse {
-  product: ProductNode;
-}
+import Link from "next/link";
 
-interface VariantImage {
-  baseImageUrl: string;
-  name: string;
-}
+// export const dynamic = 'auto'
+// // 'auto' | 'force-dynamic' | 'error' | 'force-static'
 
 async function getSingleProduct(urlKey: string) {
-  const cachedProduct = productCache.get(urlKey);
-  if (cachedProduct) {
-    return cachedProduct;
-  }
 
   try {
     const dataById = await cachedProductRequest<SingleProductResponse>(
@@ -52,9 +41,7 @@ async function getSingleProduct(urlKey: string) {
     );
 
     const product = dataById?.product || null;
-    if (product) {
-      productCache.set(urlKey, product);
-    }
+
     return product;
   } catch (error) {
     if (error instanceof Error) {
@@ -80,7 +67,9 @@ export default async function ProductPage({
   const product = await getSingleProduct(fullPath);
   if (!product) return notFound();
 
-  const imageUrl = getImageUrl(product?.baseImageUrl, baseUrl, NOT_IMAGE);
+  // const imageUrl = getImageUrl(product?.baseImageUrl, baseUrl, NOT_IMAGE);
+
+  // JSON-LD数据格式,便于搜索引擎识别页面信息,利于seo
   const productJsonLd = {
     "@context": BASE_SCHEMA_URL,
     "@type": PRODUCT_TYPE,
@@ -89,66 +78,74 @@ export default async function ProductPage({
     sku: product?.sku,
   };
 
+  const mediaImgs = (product?.images?.edges ?? []).map((edge: { node: ProductMediaType }) => {
+    return edge.node;
+  });
+  const productOptions = product?.productOptions ? JSON.parse(product?.productOptions) : [];
+
+  const flexibleVariants = (product?.flexibleVariants ?? []).map((edge: ProductFlexibleVariant ) => {
+    let node = {...edge};
+    if(typeof node.variantImages === 'string') {
+        node.variantImages = JSON.parse(node.variantImages);
+    }
+    if(typeof node.optionValues === 'string') {
+        node.optionValues = JSON.parse(node.optionValues);
+    }
+    if(typeof node.priceIndices === 'string') {
+        node.priceIndices = JSON.parse(node.priceIndices);
+    }
+    return node;
+  }) as ResolvedVariant[];
+
     const reviews = Array.isArray(product?.reviews?.edges)
     ? product?.reviews.edges.map((e) => e.node)
     : [];
 
-  const VariantImages = isArray(product?.variants?.edges)
-    ? product?.variants.edges.map(
-        (edge: { node: ProductVariantNode }) => edge.node,
-      )
-    : [];
+  // const VariantImages = isArray(product?.variants?.edges)
+  //   ? product?.variants.edges.map(
+  //       (edge: { node: ProductVariantNode }) => edge.node,
+  //     )
+  //   : [];
 
   return (
     <>
-      <MobileSearchBar />
-      <script
-        dangerouslySetInnerHTML={{
-          __html: JSON.stringify(productJsonLd),
-        }}
-        type="application/ld+json"
-      />
-      <div className="flex flex-col gap-y-4 rounded-lg pb-0 pt-4 sm:gap-y-6 md:py-7.5 lg:flex-row w-full max-w-screen-2xl mx-auto px-4 xss:px-7.5 lg:gap-8">
-        <div className="h-full w-full max-w-[885px] max-1366:max-w-[650px] max-lg:max-w-full">
-          <Suspense fallback={<HeroCarouselShimmer />}>
-            {isArray(VariantImages) ? (
-              <HeroCarousel
-                images={
-                  (VariantImages as unknown as VariantImage[])?.map(
-                    (image) => ({
-                      src:
-                        getImageUrl(image.baseImageUrl, baseUrl, NOT_IMAGE) ||
-                        "",
-                      altText: image.name || "",
-                    }),
-                  ) || []
-                }
-              />
-            ) : (
-              <HeroCarousel
-                images={[
-                  {
-                    src: imageUrl || "",
-                    altText: product?.name || "product image",
-                  },
-                ]}
-              />
-            )}
-          </Suspense>
+        <script //SEO
+            dangerouslySetInnerHTML={{
+            __html: JSON.stringify(productJsonLd),
+            }}
+            type="application/ld+json"
+        />
+        <div className="w-full">
+            <Suspense fallback={<HeroCarouselShimmer />}>
+                <ProductMedia mediaData={mediaImgs} />
+            </Suspense>
         </div>
-        <div className="basis-full lg:basis-4/6">
-          <Suspense fallback={<ProductDetailSkeleton />}>
-            <ProductInfo
-              product={product as ProductData}
-              slug={fullPath}
-              reviews={reviews as any}
-            />
-          </Suspense>
+
+
+        
+
+        <div className="">
+            <Suspense fallback={<ProductDetailSkeleton />}>
+                <ProductInformation 
+                    productId={product._id}
+                    name={product?.name || ''}
+                    productOptions={productOptions}
+                    flexibleVariants={flexibleVariants}
+                    isSaleable={product.isSaleable}
+                />
+                <ProductShortDescription shortDescription={product.shortDescription || ''} />
+                <ProductDetail detail={product.description || ''} />
+
+                <ProductReviewSection productId={String(product._id)} />
+            </Suspense>
         </div>
-      </div>
-      <Suspense fallback={<RelatedProductSkeleton />}>
-        <RelatedProductsSection fullPath={fullPath} />
-      </Suspense>
+
+
+
+        <Link href="/product/deep-wave-hd-transparent-lace-frontal-wigs-invisible-hd-lace-wigs" className="text-sm text-blue-500">Back to product</Link>
+        <Suspense fallback={<RelatedProductSkeleton />}>
+            <RelatedProductsSection fullPath={fullPath} />
+        </Suspense>
     </>
   );
 }

+ 23 - 0
src/app/(public)/product/_components/ProductAddToCart.tsx

@@ -0,0 +1,23 @@
+"use client";
+
+import clsx from "clsx";
+
+export function ProductAddToCart({
+    isAvailable,
+    onAddToCart,
+    onBuyNow
+}: { 
+    isAvailable: boolean; 
+    onAddToCart: () => void;
+    onBuyNow: () => void;
+}) { 
+
+    return (
+        <div className="fixed w-full bottom-0 left-0 bg-white box-border pt-3 pl-3 pr-3 pb-5 border-t border-solid border-ly-lightgray z-1000">
+            <div className="flex justify-between">
+                <button onClick={onBuyNow} type="button" className="flex-initial flex items-center justify-center w-40 h-ly-46 rounded-4xl text-ly-16 font-bold text-white bg-ly-deepgreen">Buy Now</button>
+                <button onClick={onAddToCart} type="button" className="flex-initial flex items-center justify-center w-40 h-ly-46 rounded-4xl text-ly-16 font-bold text-white bg-ly-middlegreen">Add To Cart</button>
+            </div>
+        </div>
+    );
+}

+ 18 - 0
src/app/(public)/product/_components/ProductDetail.tsx

@@ -0,0 +1,18 @@
+"use client";
+
+export function ProductDetail({
+    detail, 
+} : {
+    detail:string;
+}) { 
+    return (
+        <>
+            <div className="box-border w-full pr-4 pl-4">
+                <h3>Product Details</h3>
+                <div dangerouslySetInnerHTML={{ __html: detail }}></div>
+            </div>
+            
+        </>
+
+    );
+}

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

@@ -0,0 +1,445 @@
+"use client";
+
+import { useState, useMemo, useEffect } from "react";
+import clsx from "clsx";
+import { 
+    ProductOption, 
+    ResolvedVariant
+} from "@/components/catalog/type";
+import { ProductAddToCart } from "@/app/(public)/product/_components/ProductAddToCart";
+import { useCustomToast } from "@utils/hooks/useToast";
+import {clientFetch} from "@/lib/restApiClient";
+
+/**
+ * 辅助函数:判断一个完整的选项值ID组合是否对应一个可购买的变体(quantity > 0)
+ * @param selectedIds {number[]} 选中的选项值ID的数组
+ * @param variants {ResolvedVariant[]} 变体列表
+ * @returns {boolean} 该选项组和对应的变体是否可用
+ *  */ 
+function isCombinationAvailable(
+    selectedIds: number[],
+    variants: ResolvedVariant[]
+): boolean {
+    // some 每一项都带入参数函数执行,如果遇到一项带入函数执行结果是true,则返回true;如果所有项带入函数执行,结果都是false,则返回false
+    return variants.some((variant) => {
+        if (variant.quantity <= 0) return false;
+        const values = variant.optionValues;
+
+        const variantValueIds = values.map((v) => v.id);
+        return (
+            selectedIds.length === variantValueIds.length &&
+            selectedIds.every((id) => variantValueIds.includes(id))
+        );
+    });
+}
+
+/** 
+ * 辅助函数:根据部分选中的映射,判断某个选项值是否可用
+ * @param optionId {number} 选项组ID
+ * @param valueId {number} 选项值ID
+ * @param currentSelected {Record<number, number>} 当前已选中的映射
+ * @param allOptions 产品选项列表
+ * @param variants 产品变体列表
+ * @returns {boolean} 该选项值是否可用
+ * */ 
+function isOptionValueAvailable(
+    optionId: number,
+    valueId: number,
+    currentSelected: Record<number, number>, // 当前已选中的映射
+    allOptions: ProductOption[],
+    variants: ResolvedVariant[]
+): boolean {
+    const testSelected = { ...currentSelected, [optionId]: valueId };
+    // 检查是否所有选项组都已选中(有些组可能尚未选择,但禁用判断时我们只关心已选中的组能否与当前测试值组成可用变体)
+    // 获取所有选项值ID的列表
+    const selectedValueIds = Object.values(testSelected);
+    // 如果有任何组未选中,则无法构成完整变体,此时应视为可能可用(不禁用),因为用户还需要继续选择
+    if (selectedValueIds.length < allOptions.length) {
+        // 但需要检查:在已选中的这些组中,是否存在一个变体其对应的值包含这些已选中的值?
+        // 即是否存在变体,其选项值集合包含当前测试选中的值(作为子集)
+        return variants.some((variant) => {
+            if (variant.quantity <= 0) return false;
+            const values = variant.optionValues;
+            const variantValueIds = values.map((v) => v.id);
+            // 检查测试选中的值是否都是该变体选项值的子集
+            return selectedValueIds.every((id) => variantValueIds.includes(id));
+        });
+    } else {
+        // 所有组都已选中,直接检查完整组合是否可用
+        return isCombinationAvailable(selectedValueIds, variants);
+    }
+}
+/**
+ * 根据传入的value id判断变体中是否存在可购买变体,如果有返回true,如果没有则返回false
+ * @param valueId {number} 选项值ID
+ * @param variants {ResolvedVariant[]} 变体列表
+ * @returns {boolean} 该value id是否可用
+ * */
+function isValueAvailable( valueId: number, variants: ResolvedVariant[]): boolean {
+    return variants.some((variant) => {
+        if (variant.quantity <= 0) return false;
+        const values = variant.optionValues;
+        const variantValueIds = values.map((v) => v.id);
+        return variantValueIds.includes(valueId);
+    });
+}
+/** 
+ * 根据传入的value id组合,找到一个最近的可购买的变体
+ * @param fixedOptionId {number} 调过校验的选项组id
+ * @param fixedValueId {number} 调过校验的选项值id
+ * @param valueIdMap {Record<number, number>} 选中的选项值映射
+ * @param allOptions 产品的选项列表
+ * @param variants 产品的变体列表
+ * @returns {Record<number, number>} 返回一个最近可用变体的选项值映射
+ * */
+function findNearestAvailableVariant(
+    fixedOptionId: number,
+    fixedValueId: number,
+    valueIdMap: Record<number, number>, 
+    allOptions: ProductOption[],
+    variants: ResolvedVariant[] // 可购买的变体列表
+): Record<number, number>{
+    let res:Record<number, number> = {[fixedOptionId]: fixedValueId};
+
+    for (const option of allOptions) {
+        let optionId = option.id;
+        if(fixedOptionId === optionId) continue;
+        let valueId = valueIdMap[optionId];
+        if(isOptionValueAvailable(optionId,valueId,res,allOptions,variants)) {
+            res = {...res,[optionId]: valueId};
+        } else {
+            let found = false;
+            for(let i = 0; i < option.values.length; i++) {
+                if(isOptionValueAvailable(optionId,option.values[i].id,res,allOptions,variants)) {
+                    found = true;
+                    res = {...res,[optionId]: option.values[i].id};
+                    break;
+                }
+            }
+            if (!found) {
+                // 极端情况:找不到可用值,保留原值以维持完整状态
+                res = { ...res, [optionId]: valueId };
+            }
+        }
+    }
+
+    return res;
+}
+export function ProductInformation({
+    name,
+    productId,
+    productOptions,
+    flexibleVariants,
+    isSaleable
+}: {
+    name: string;
+    productId: number;
+    productOptions: ProductOption[];
+    flexibleVariants: ResolvedVariant[];
+    isSaleable: string | undefined;
+}) {
+
+    const { showToast } = useCustomToast();
+    
+    // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
+    const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0].id );
+
+    const [productQty, setProductQty] = useState(1);
+
+    // 获取所有可用变体(quantity > 0)
+    const availableVariants = useMemo(() => {
+        return flexibleVariants.filter((v) => v.quantity > 0);
+    }, [flexibleVariants]);
+
+    // 当前选中的选项值映射:option_id -> value_id
+    // const [selected, setSelected] = useState<Record<number, number>>({});
+
+    const [selected, setSelected] = useState<Record<number, number>>(() => {
+        let res: Record<number, number> = {};
+        // 递归回溯查找第一个可用组合
+        const findFirstAvailable = ( optionIndex: number, currentMap: Record<number, number> ): Record<number, number> | null => {
+            if (optionIndex >= productOptions.length) {
+                // 所有组都已选,检查完整组合是否可用
+                const valueIds = Object.values(currentMap);
+                return isCombinationAvailable(valueIds, flexibleVariants) ? { ...currentMap } : null;
+            }
+
+            const option = productOptions[optionIndex];
+            for (const value of option.values) {
+                const nextMap = { ...currentMap, [option.id]: value.id };
+                // 剪枝:检查当前部分组合是否可能导向可用变体
+                const partialValueIds = Object.values(nextMap);
+                const hasPotential = flexibleVariants.some((v) => {
+                    if (v.quantity <= 0) return false;
+                    const vIds = v.optionValues.map((ov) => ov.id);
+                    return partialValueIds.every((id) => vIds.includes(id));
+                });
+                if (!hasPotential) continue;
+
+                const result = findFirstAvailable(optionIndex + 1, nextMap);
+                if (result) return result;
+            }
+            return null;
+        };
+
+        const defaultSelected = findFirstAvailable(0, {});
+        if (defaultSelected) {
+            res = defaultSelected;
+        } else {
+            // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
+            productOptions.forEach((opt) => {
+                res[opt.id] = opt.values[0]?.id;
+            });
+        }
+        console.log('set default selected');
+        return res;
+    });
+    /****
+    // useEffect在浏览器绘制后执行
+    // 初始化默认选中:找到第一个可用的完整组合
+    useEffect(() => {
+        // 如果已经初始化过,不再重复执行
+        if (Object.keys(selected).length > 0) return;
+
+        // 递归回溯查找第一个可用组合
+        const findFirstAvailable = ( optionIndex: number, currentMap: Record<number, number> ): Record<number, number> | null => {
+            if (optionIndex >= productOptions.length) {
+                // 所有组都已选,检查完整组合是否可用
+                const valueIds = Object.values(currentMap);
+                return isCombinationAvailable(valueIds, flexibleVariants) ? { ...currentMap } : null;
+            }
+
+            const option = productOptions[optionIndex];
+            for (const value of option.values) {
+                const nextMap = { ...currentMap, [option.id]: value.id };
+                // 剪枝:检查当前部分组合是否可能导向可用变体
+                const partialValueIds = Object.values(nextMap);
+                const hasPotential = flexibleVariants.some((v) => {
+                    if (v.quantity <= 0) return false;
+                    const vIds = v.optionValues.map((ov) => ov.id);
+                    return partialValueIds.every((id) => vIds.includes(id));
+                });
+                if (!hasPotential) continue;
+
+                const result = findFirstAvailable(optionIndex + 1, nextMap);
+                if (result) return result;
+            }
+            return null;
+        };
+
+        const defaultSelected = findFirstAvailable(0, {});
+        if (defaultSelected) {
+            setSelected(defaultSelected);
+        } else {
+            // 极端情况:没有任何可用组合,默认全选每组第一个(但仍需标记为不可用,由禁用逻辑处理)
+            const fallback: Record<number, number> = {};
+            productOptions.forEach((opt) => {
+                fallback[opt.id] = opt.values[0]?.id;
+            });
+            setSelected(fallback);
+        }
+        console.log('set default selected');
+    }, [productOptions, flexibleVariants, selected]);
+    ****/
+    // 计算每个选项值的禁用状态
+    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};
+        let selectedValueIds = Object.values(newSelected);
+        if(!isCombinationAvailable(selectedValueIds, availableVariants)) {
+            newSelected = findNearestAvailableVariant(optionId,valueId,{...newSelected},productOptions,availableVariants);
+        }
+        setSelected({ ...newSelected });
+        // 记录当前点击的选项组
+        setLastClickedOptionId(optionId);
+    };
+
+    // 判断当前选中的组合是否可购买(用于控制购买按钮等)
+    const isCurrentSelectionAvailable = useMemo(() => {
+        let res = true;
+        if(isSaleable !== '1') {
+            res = false;
+        } else {
+            const selectedIds = Object.values(selected);
+            if (selectedIds.length !== productOptions.length) {
+                res = false
+            } else {
+                res = isCombinationAvailable(selectedIds, availableVariants);
+            }
+        }
+        return res;
+    }, [selected, productOptions, flexibleVariants, isSaleable]);
+
+    // 获取当前选中变体和价格等信息
+    // useEffect和useMemo的执行时机不同,useMemo早于useEffect执行,如果使用useEffect初始化selected,则需要对selected做判断,当为空对象时返回占位对象
+    const currentVariantInfo: {variant:ResolvedVariant | null; totalLinePrice: number; totalNowPrice: number; save: number;} = useMemo(() => {
+        const selectedIds = Object.values(selected);
+        if (selectedIds.length === 0) {
+            // 尚未初始化,返回一个安全占位对象
+            return { variant: null, totalLinePrice: 0, totalNowPrice: 0, save: 0 };
+        }
+        let totalLinePrice = 0;
+        let totalNowPrice = 0;
+        let save = 0;
+        let variant = flexibleVariants.find((v) => {
+            const vIds = v.optionValues.map((ov) => ov.id);
+            return selectedIds.length === vIds.length && selectedIds.every((id) => vIds.includes(id));
+        });
+        // 类型守卫
+        if(variant === undefined) {
+            // 显式断言 + 运行时保护
+            throw new Error(`Unexpected: no variant matches selected ${JSON.stringify(selected)}`);
+        }
+        if(variant.priceIndices) {
+            totalLinePrice = Number(variant.priceIndices[0].min_price) * productQty;
+            totalNowPrice = Number(variant.priceIndices[0].regular_min_price) * productQty;
+        }
+        if(totalLinePrice !== totalNowPrice) {
+            save = totalLinePrice - totalNowPrice; 
+        }
+
+        return {
+            variant, 
+            totalLinePrice: totalLinePrice, 
+            totalNowPrice: totalNowPrice,
+            save: save
+        };
+    }, [selected, flexibleVariants, productQty]);
+
+
+    let classNameOptionValueBtn = "text-ly-14 leading-ly-24 border border-solid rounded-lg pt-1.5 pb-1.5 pl-3 pr-3 bg-white";
+
+
+    let handlerProductQty = (action: string) => {
+        if (action === "increase") {
+            setProductQty(productQty + 1);
+        } else if (action === "decrease" && productQty > 1) {
+            setProductQty(productQty - 1);
+        }
+    }
+
+
+    let addToCartHandler = async () => {
+        // /api/test
+        // let result = await clientFetch('/api/shop/categories/1',{
+        //     method: "GET",
+        // });
+        let result = await clientFetch('/api/shop/addProductInCart',{
+            method: "POST",
+            body: JSON.stringify({ productId: productId, quantity: productQty, variantId: currentVariantInfo.variant?.id }),
+        });
+        console.log(result);
+        //if(!isCurrentSelectionAvailable) {
+            // showToast("Please fill in all required fields", "warning");
+            // return;
+        //}
+    };
+
+    let buyNowHandler = () => {};
+
+
+    return (<>
+        <div className="box-border w-full pr-4 pl-4">
+            <h3 className="text-ly-12 mt-4 leading-ly-22">{name} {isSaleable}</h3>
+      
+            <div className="flex items-center mt-2">
+                <span className="text-ly-16 leading-ly-24 font-bold">
+                    {currentVariantInfo.totalNowPrice.toFixed(2)}
+                </span>
+                {
+                    currentVariantInfo.totalLinePrice !== currentVariantInfo.totalNowPrice && (
+                        <>
+                            <span className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1">
+                                {currentVariantInfo.totalLinePrice.toFixed(2)}
+                            </span>
+                            <span className="text-ly-12 leading-ly-16 bg-ly-gold pr-1.5 pl-1.5 ml-3">
+                                -{currentVariantInfo.save.toFixed(2)}
+                            </span>
+                        </>
+                    )
+                }
+                
+            </div>
+            
+        </div>
+
+        <div className="box-border w-full pr-4 pl-4 mt-6">
+            {productOptions.map((option) => (
+            <div key={option.id}>
+                <div className="text-ly-12 font-medium">{option.label}</div>
+                <div className="flex flex-wrap gap-3 mt-3">
+                {option.values.map((value) => {
+                    const isSelected = selected[option.id] === value.id;
+                    const isDisabled = disabledMap[value.id] || false;
+                    return (
+                    <button
+                        key={value.id}
+                        type="button"
+                        onClick={() => !isDisabled && handleOptionClick(option.id, value.id)}
+                        className={clsx(classNameOptionValueBtn, {
+                            'border-ly-green text-ly-green': isSelected,
+                            'border-black text-black': !isSelected,
+                            'opacity-40 cursor-not-allowed strokeline-bg-disabled': isDisabled
+                        })}
+                        
+                        disabled={isDisabled}
+                    >
+                        {value.label}
+                    </button>
+                    );
+                })}
+                </div>
+            </div>
+            ))}
+
+            <div className="text-ly-12 font-medium mt-4">Quantity*</div>
+            <div className="w-full flex mt-3">
+                <div className="flex border border-solid rounded-lg">
+                    <button onClick={() => handlerProductQty('decrease')} type="button" className="flex-initial w-7.5 h-9 flex items-center justify-center">
+                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8" />
+                        </svg>
+                    </button>
+                    <input type="text" className="w-15 h-9 leading-ly-36 text-center" value={productQty} readOnly />
+                    <button onClick={() => handlerProductQty('increase')} type="button" className="flex-initial w-7.5 h-9 flex items-center justify-center">
+                        <svg className="w-4 h-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M8.02026 3.3335L8.00806 12.6668" />
+                            <path stroke="rgba(0, 0, 0, 1)" strokeWidth="1.5" strokeLinejoin="round" d="M3.33325 8L12.6666 8" />
+                        </svg>
+                    </button>
+                </div>
+            </div>
+        </div>
+        <ProductAddToCart 
+            isAvailable={isCurrentSelectionAvailable} 
+            onAddToCart={addToCartHandler}
+            onBuyNow={buyNowHandler}
+        />
+    </>);
+}

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

@@ -0,0 +1,58 @@
+"use client";
+
+import Image from 'next/image'
+import 'swiper/css';
+// import 'swiper/css/pagination';
+import { Swiper, SwiperSlide } from 'swiper/react';
+import { Pagination } from 'swiper/modules';
+import type {Swiper as SwiperType} from 'swiper';
+import type { PaginationOptions } from 'swiper/types';
+import { ProductMediaType } from "@/components/catalog/type";
+import {
+  baseUrl,
+  getImageUrl
+} from "@/utils/constants";
+export function ProductMedia({ mediaData }: {mediaData: Array<ProductMediaType>; }) {
+
+    const pagination: PaginationOptions = {
+        el: '.product-media-swiper-pagination',
+        type: 'custom',
+        renderCustom: function (swiper: SwiperType, current: number, total: number) {
+            return current + '/' + total;
+        },
+    };
+    let mediaJSX = <></>;
+    if(mediaData.length) {
+        mediaJSX = (
+            <Swiper
+                pagination={pagination}
+                modules={[Pagination]}
+                className="product-media-swiper"
+                onSlideChange={() => console.log('slide change')}
+                onSwiper={(swiper) => console.log(swiper)}
+            >
+                {
+                    mediaData.map((img) => {
+                        return (
+                            <SwiperSlide>
+                                <Image className="block w-full"
+                                    src={getImageUrl(img.publicPath,baseUrl)}
+                                    width={390}
+                                    height={520}
+                                    loading="eager"
+                                    alt="Picture of the author"
+                                />
+                            </SwiperSlide>
+                        );
+                    }) 
+                }
+                
+                <div slot="container-end">
+                    <div className="product-media-swiper-pagination z-1 absolute bottom-6 left-1/2 transform-[translateX(-50%)] pt-1.5 pb-1.5 pl-3 pr-3 rounded-3xl text-ly-14 text-white bg-ly-deepgreen/50"></div>
+                </div>
+            </Swiper>
+        );
+    }
+    return mediaJSX;
+
+}

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

@@ -0,0 +1,32 @@
+import ReviewAdd from "@/app/(public)/product/_components/review/ReviewAdd";
+import ReviewDetail from "@/app/(public)/product/_components/review/ReviewDetail";
+import { NoReview } from "@/app/(public)/product/_components/review/NoReview";
+import { getProductReviews } from "@utils/hooks/getProductReviews";
+
+
+export async function ProductReviewSection({
+    productId, 
+} : {
+    productId:string;
+}) { 
+    const getAllreviews = await getProductReviews(productId)
+    return (
+        <>
+            <div className="box-border w-full pr-4 pl-4">
+                <ReviewAdd productId={productId} />
+                {getAllreviews.length > 0 ? (
+                  <>
+                    <ReviewDetail
+                      reviewDetails={getAllreviews}
+                      totalReview={getAllreviews.length}
+                      productId={productId}
+                    />
+                  </>
+                ) : ( <NoReview />)}
+
+            </div>
+            
+        </>
+
+    );
+}

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

@@ -0,0 +1,18 @@
+"use client";
+
+export function ProductShortDescription({
+    shortDescription, 
+} : {
+    shortDescription:string;
+}) { 
+    return (
+        <>
+            <div className="box-border w-full pr-4 pl-4">
+                <h3>Product Description</h3>
+                <div dangerouslySetInnerHTML={{ __html: shortDescription }}></div>
+            </div>
+            
+        </>
+
+    );
+}

+ 278 - 0
src/app/(public)/product/_components/review/AddProductReview.tsx

@@ -0,0 +1,278 @@
+"use client";
+
+import { useState } from "react";
+import { Textarea } from "@heroui/react";
+import { AddRatingStar } from "./AddRatingStar";
+import { Button } from "@components/common/button/Button";
+import { useCustomToast } from "@utils/hooks/useToast";
+import { useProductReview } from "@utils/hooks/useProductReview";
+import Image from "next/image";
+
+import { CreateProductReviewInput } from "@/types/review";
+import { AddUploadImage } from "@components/common/icons/AddUploadImage";
+
+export default function AddProductReview({
+  productId,
+  onClose,
+}: {
+  productId: string;
+  onClose: () => void;
+}) {
+  const [imageFile, setImageFile] = useState<string | null>(null);
+  const [imagePreview, setImagePreview] = useState<string | null>(null);
+  const [errors, setErrors] = useState({
+    title: "",
+    comment: "",
+    rating: "",
+  });
+  const [reviewInfo, setReviewInfo] = useState({
+    title: "",
+    comment: "",
+    rating: 0,
+    attachments: null as string | null,
+  });
+  const { showToast } = useCustomToast();
+  const { createProductReview, isLoading } = useProductReview();
+
+  const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (file) {
+      setImageFile(file.name);
+      setReviewInfo((prev) => ({ ...prev, attachments: file.name }));
+      const reader = new FileReader();
+      reader.onloadend = () => {
+        setImagePreview(reader.result as string);
+      };
+      reader.readAsDataURL(file);
+    }
+  };
+
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+
+    const newErrors = {
+      title: "",
+      comment: "",
+      rating: "",
+    };
+
+    if (!reviewInfo.title.trim()) {
+      newErrors.title = "Title is required";
+    }
+
+    if (!reviewInfo.comment.trim()) {
+      newErrors.comment = "Comment is required";
+    }
+
+    if (reviewInfo.rating === 0) {
+      newErrors.rating = "Please select a rating";
+    }
+
+    setErrors(newErrors);
+
+    // If there are errors, don't submit
+    if (newErrors.title || newErrors.comment || newErrors.rating) {
+      showToast("Please fill in all required fields", "danger");
+      return;
+    }
+
+    try {
+      const input: CreateProductReviewInput = {
+        productId: Number(productId.split("/").pop()),
+        title: reviewInfo.title,
+        comment: reviewInfo.comment,
+        rating: reviewInfo.rating,
+        name: "Guest User",
+        email: "guest@mail.com",
+        attachments: "",
+      };
+
+      await createProductReview(input);
+
+      setReviewInfo({
+        title: "",
+        comment: "",
+        rating: 0,
+        attachments: null,
+      });
+      setImageFile(null);
+      setImagePreview(null);
+      setErrors({
+        title: "",
+        comment: "",
+        rating: "",
+      });
+    } catch (error) {
+      console.error("Error submitting review:", error);
+      showToast("Failed to submit review. Please try again.", "danger");
+    }
+  };
+
+  return (
+    <form
+      onSubmit={handleSubmit}
+      className="w-full max-w-4xl mx-auto p-4 md:p-6 rounded-xl relative"
+    >
+      <div className="flex mb-4">
+        <h1 className="text-xl font-semibold">Write a review</h1>
+        <button
+          type="button"
+          onClick={onClose}
+          className="absolute top-4 right-4 z-50 p-2 rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-neutral-300 dark:hover:bg-neutral-700 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400 transition-colors"
+          aria-label="Close review form"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            className="h-5 w-5"
+            fill="none"
+            viewBox="0 0 24 24"
+            stroke="currentColor"
+          >
+            <path
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              strokeWidth={2}
+              d="M6 18L18 6M6 6l12 12"
+            />
+          </svg>
+        </button>
+      </div>
+      <div className="flex flex-col gap-4">
+        <div className="space-y-4">
+          {imagePreview ? (
+            <div className="border-2 w-[120px] h-auto max-h-[120px] border-dashed border-gray-300  rounded-xl text-center transition-colors hover:border-primary-500">
+              <div className="space-y-3">
+                <div className="relative mx-auto">
+                  <Image
+                    src={imagePreview}
+                    alt="Preview"
+                    width={120}
+                    height={120}
+                    className="w-full h-full object-cover rounded-lg"
+                  />
+                  <button
+                    type="button"
+                    onClick={() => {
+                      setImageFile(null);
+                      setImagePreview(null);
+                    }}
+                    className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600 transition-colors"
+                  >
+                    <svg
+                      xmlns="http://www.w3.org/2000/svg"
+                      className="h-4 w-4"
+                      fill="none"
+                      viewBox="0 0 24 24"
+                      stroke="currentColor"
+                    >
+                      <path
+                        strokeLinecap="round"
+                        strokeLinejoin="round"
+                        strokeWidth={2}
+                        d="M6 18L18 6M6 6l12 12"
+                      />
+                    </svg>
+                  </button>
+                </div>
+              </div>
+            </div>
+          ) : (
+            <div className="border-2 border-dashed border-gray-300 w-32 h-32 rounded-xl p-6 text-center transition-colors hover:border-primary-500">
+              <div>
+                <label
+                  htmlFor="file-upload"
+                  className="mx-auto flex cursor-pointer flex-col items-center justify-center gap-2"
+                >
+                  <div className="mx-auto flex h-8 w-8 items-center justify-center rounded-full">
+                    <AddUploadImage />
+                  </div>
+
+                  <div className="text-sm text-center">
+                    <span className="font-medium">Add Image</span>
+                  </div>
+
+                  <input
+                    id="file-upload"
+                    name="file-upload"
+                    type="file"
+                    className="sr-only"
+                    accept="image/*"
+                    onChange={handleImageUpload}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+          {imageFile && <p className="text-sm">{imageFile}</p>}
+        </div>
+
+        <div className="flex flex-col gap-4">
+          <div>
+            <label className="block text-base font-semibold  mb-1">
+              Rating
+            </label>
+            <AddRatingStar
+              value={reviewInfo.rating}
+              onChange={(value) => {
+                setReviewInfo((prev) => ({ ...prev, rating: value }));
+                setErrors((prev) => ({ ...prev, rating: "" }));
+              }}
+              size="size-6"
+              className="mt-1"
+            />
+            {errors.rating && (
+              <p className="text-red-500 text-sm mt-1">{errors.rating}</p>
+            )}
+          </div>
+
+          <Textarea
+            label="Title"
+            placeholder="Title"
+            labelPlacement="outside"
+            value={reviewInfo.title}
+            onChange={(e) => {
+              setReviewInfo((prev) => ({ ...prev, title: e.target.value }));
+              setErrors((prev) => ({ ...prev, title: "" }));
+            }}
+            minRows={1}
+            maxRows={1}
+            variant="bordered"
+            isInvalid={!!errors.title}
+            errorMessage={errors.title}
+            classNames={{
+              input: "text-sm",
+              label: "px-2 text-base font-semibold",
+            }}
+          />
+
+          <Textarea
+            label="Comment"
+            placeholder="Comment"
+            labelPlacement="outside"
+            value={reviewInfo.comment}
+            onChange={(e) => {
+              setReviewInfo((prev) => ({ ...prev, comment: e.target.value }));
+              setErrors((prev) => ({ ...prev, comment: "" }));
+            }}
+            minRows={5}
+            variant="bordered"
+            isInvalid={!!errors.comment}
+            errorMessage={errors.comment}
+            classNames={{
+              input: "text-sm",
+              label: "px-2 text-base font-semibold",
+            }}
+          />
+          <div className="w-32">
+            <Button
+              title={isLoading ? "Submitting..." : "Submit"}
+              type="submit"
+              disabled={isLoading}
+              className="w-full mt-4 rounded-2xl"
+            />
+          </div>
+        </div>
+      </div>
+    </form>
+  );
+}

+ 68 - 0
src/app/(public)/product/_components/review/AddRatingStar.tsx

@@ -0,0 +1,68 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import clsx from "clsx";
+import { StarIcon } from "@heroicons/react/24/solid";
+import { RatingTypes } from "../type";
+
+
+export const AddRatingStar = ({
+    length = 5,
+    value,
+    size = "size-6",
+    className,
+    onChange
+}: RatingTypes) => {
+
+    const [internalValue, setInternalValue] = useState(value ?? 0);
+    const [hovered, setHovered] = useState<number | null>(null);
+
+    useEffect(() => {
+        if (value !== undefined) {
+              // eslint-disable-next-line react-hooks/set-state-in-effect
+            setInternalValue(value);
+        }
+    }, [value]);
+
+    const currentValue = value ?? internalValue;
+
+    const getFillState = (index: number) => {
+        if (hovered !== null) return index <= hovered;
+        return index <= currentValue;
+    };
+
+    const handleClick = (index: number) => {
+        if (value === undefined) {
+               
+            setInternalValue(index);
+        }
+        onChange?.(index);
+    };
+
+
+    return (
+        <div className={clsx("flex items-center gap-x-1 cursor-pointer", className)}>
+            {Array.from({ length }).map((_, i) => {
+                const index = i + 1;
+                return (
+                    <StarIcon
+                        key={index}
+                        onClick={() => handleClick(index)}
+                        onMouseEnter={() => setHovered(index)}
+                        onMouseLeave={() => setHovered(null)}
+                        fill="currentColor"
+                        className={clsx(
+                            "fill-current",
+                            size || "size-6",
+                            getFillState(index)
+                                ? "text-yellow-500"
+                                : "text-gray-300",
+                            "transition-colors"
+                        )}
+                    />
+                );
+            })}
+        </div>
+    );
+};
+

+ 17 - 0
src/app/(public)/product/_components/review/NoReview.tsx

@@ -0,0 +1,17 @@
+import { NoReviewIcon } from "@components/common/icons/NoReviewsIcon";
+
+export const NoReview = () => {
+  return (
+    <div className="flex flex-col items-center gap-4 py-8">
+      <div className="flex flex-col items-center gap-4">
+        <NoReviewIcon />
+        <h2 className="font-outfit text-2xl font-semibold tracking-wide mt-4">
+          Ratings
+        </h2>
+        <p className="text-lg mt-2">
+          No reviews yet. Be the first to share your experience
+        </p>
+      </div>
+    </div>
+  );
+};

+ 47 - 0
src/app/(public)/product/_components/review/ReviewAdd.tsx

@@ -0,0 +1,47 @@
+"use client";
+
+import { useState } from "react";
+import { Modal, ModalContent } from "@heroui/react";
+import AddProductReview from "./AddProductReview";
+import { ReviewButton } from "@components/common/button/ReviewButton";
+
+
+export default function ReviewAdd({
+  productId,
+}: {productId: string;}) {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <>
+      <div>
+          <ReviewButton setShowForm={setOpen} />
+      </div>
+
+      <Modal
+        isOpen={open}
+        onOpenChange={setOpen}
+        backdrop="blur"
+        size="4xl"
+        hideCloseButton
+        scrollBehavior="inside"
+        placement="center"
+        classNames={{
+          wrapper: "z-[100] items-center justify-center",
+          backdrop: "z-[99]",
+          base: "bg-white/90 dark:bg-black/80 backdrop-blur-xl  rounded-xl mx-4",
+        }}
+      >
+        <ModalContent className="p-0 border-none">
+          {(onClose) => (
+            <AddProductReview
+              productId={productId}
+              onClose={onClose}
+            />
+          )}
+        </ModalContent>
+      </Modal>
+    </>
+  );
+}
+
+

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

@@ -0,0 +1,199 @@
+"use client";
+
+import { Rating } from "@components/common/Rating";
+import { GridTileImage } from "@components/theme/ui/grid/Tile";
+import { formatDate, getInitials, getReviews } from "@/utils/helper";
+import { isArray } from "@/utils/type-guards";
+import { Avatar, Tooltip } from "@heroui/react";
+import clsx from "clsx";
+import { FC, useState } from "react";
+import { ProductReviewNode } from "@/components/catalog/type";
+
+interface ProductReviewEdge {
+  __typename: "ProductReviewEdge";
+  node: ProductReviewNode;
+}
+
+interface ReviewDetailProps {
+  reviewDetails: ProductReviewEdge[];
+  totalReview: number;
+  productId: string;
+}
+
+const ReviewDetail: FC<ReviewDetailProps> = ({
+  reviewDetails,
+  totalReview,
+  productId,
+}) => {
+  const [visibleCount, setVisibleCount] = useState(5);
+
+  const reviews: ProductReviewNode[] =
+    reviewDetails?.map((edge) => edge.node) || [];
+
+  const { reviewAvg, ratingCounts } = getReviews(reviews);
+
+  const visibleReviews = reviews.slice(0, visibleCount);
+
+  return (
+    <>
+      <div className="flex flex-col flex-wrap gap-x-5 sm:gap-x-10">
+        <div className="my-2 flex w-full flex-col flex-wrap justify-between gap-4 sm:flex-row sm:items-center min-[1350px]:flex-nowrap">
+          <div className="flex items-center gap-x-2">
+            <Rating
+              length={5}
+              size="size-5"
+              star={reviewAvg}
+              reviewCount={totalReview}
+            />
+          </div>
+
+          <div className="flex w-full max-w-[280px] overflow-hidden rounded-sm">
+            {Object.entries(ratingCounts)
+              .reverse()
+              .filter(([_, count]) => (count as number) > 0)
+              .map(([star, count]) => (
+                <div
+                  key={star}
+                  style={{ flex: count as number }}
+                  className="min-h-4"
+                >
+                  <Tooltip
+                    content={
+                      <p className="text-center">
+                        {star} Star <br /> {count as number}{" "}
+                        {(count as number) >= 2 ? "Reviews" : "Review"}
+                      </p>
+                    }
+                    placement="top"
+                    className="cursor-pointer"
+                  >
+                    <div
+                      className={clsx(
+                        "h-full w-full !cursor-pointer",
+                        star === "5"
+                          ? "bg-green-700"
+                          : star === "4"
+                            ? "bg-cyan-400"
+                            : star === "3"
+                              ? "bg-violet-600"
+                              : star === "2"
+                                ? "bg-yellow-400"
+                                : "bg-red-600",
+                      )}
+                    />
+                  </Tooltip>
+                </div>
+              ))}
+          </div>
+        </div>
+
+        <div className="flex w-full flex-1 flex-col gap-5 py-2 sm:pt-6">
+          {/* Scrollable Container */}
+          <div
+            className="max-h-[380px] overflow-y-auto pr-2 
+        scrollbar-thin scrollbar-track-transparent 
+        scrollbar-thumb-neutral-400 dark:scrollbar-thumb-neutral-600"
+          >
+            {visibleReviews &&
+              visibleReviews.map(
+                (
+                  {
+                    name,
+                    title,
+                    comment,
+                    createdAt,
+                    rating,
+                    images,
+                    customer,
+                  }: ProductReviewNode,
+                  index: number,
+                ) => (
+                  <div
+                    key={index}
+                    className={clsx("flex flex-col gap-y-2 py-3", {
+                      "border-b border-neutral-200 dark:border-neutral-700":
+                        visibleReviews.length > 1 &&
+                        index < visibleReviews.length - 1,
+                    })}
+                  >
+                    <h1 className="font-outfit text-xl font-medium text-black/[80%] dark:text-white">
+                      {title}
+                    </h1>
+                    <Rating
+                      className="my-1"
+                      length={5}
+                      size="size-5"
+                      star={rating}
+                    />
+
+                    <h2 className="font-outfit text-base font-normal text-black/[80%] dark:text-white">
+                      {comment}
+                    </h2>
+
+                    <div className="flex gap-4">
+                      <div className="flex w-full items-center gap-2">
+                        <Avatar
+                          className="h-[56px] min-w-[56px] border border-solid border-black/10 bg-white text-large dark:bg-neutral-900"
+                          name={getInitials(name)}
+                          src={customer?.imageUrl}
+                        />
+                        <div>
+                          <h1 className="font-outfit text-base font-medium text-black/80 dark:text-white">
+                            {name}
+                          </h1>
+                          <p className="text-base text-black/80 dark:text-white">
+                            {formatDate(createdAt)}
+                          </p>
+                        </div>
+                      </div>
+                    </div>
+
+                    {isArray(images) && images && images.length > 0 && (
+                      <div className="mt-2 flex h-full min-h-[50px] w-full max-w-[60px] flex-wrap gap-2">
+                        {images.map((img) => (
+                          <GridTileImage
+                            key={img.reviewId}
+                            fill
+                            alt={`${img.reviewId}-review`}
+                            className="rounded-lg"
+                            src={img.url}
+                          />
+                        ))}
+                      </div>
+                    )}
+                  </div>
+                ),
+              )}
+
+            {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 More</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>
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default ReviewDetail;

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

@@ -1,6 +1,63 @@
 export interface SingleProductResponse {
   product: ProductNode;
 }
+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 ProductFlexibleVariant {
+  id: string;
+  _id: number;
+  sku: string;
+  variantImages: string | null | Array<{id: number;position: number;url: string;}>; 
+  optionValues: string | Array<ProductFlexibleVariantValue>
+  quantity:number;
+  price: string;
+  priceIndices: string | null | Array<ProductFlexibleVariantPriceIndices>;
+}
+
+export interface ProductMediaType{
+  id: string;
+  path: string;
+  publicPath: string;
+  positionpublicPath: string; 
+}
+
+// 定义解析后的类型(根据最初类型裁剪)
+export type ResolvedVariant = Omit<ProductFlexibleVariant, 'optionValues' | 'variantImages | priceIndices'> & {
+  optionValues: ProductFlexibleVariantValue[];
+  variantImages: Exclude<ProductFlexibleVariant['variantImages'], string>; // 去掉 string 可能性
+  priceIndices: Exclude<ProductFlexibleVariant['priceIndices'], string>;
+};
 
 export interface ProductSectionNode {
   isSaleable: string | undefined;
@@ -43,17 +100,24 @@ export interface ProductNode {
     edges: Array<{ node: ProductVariantNode }>;
   };
   id: string;
+  _id: number;
   sku: string;
   type: string;
   name?: string;
   urlKey?: string;
+  isSaleable?: string | undefined;
   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;
+  productOptions?: string | null;
+  flexibleVariants?: Array<ProductFlexibleVariant>;
   priceHtml?: {
     currencyCode?: string;
   };

+ 29 - 1
src/graphql/catalog/fragments/ProductDetailed.ts

@@ -3,6 +3,7 @@ import { gql } from "@apollo/client";
 export const PRODUCT_DETAILED_FRAGMENT = gql`
   fragment ProductDetailed on Product {
     id
+    _id
     sku
     type
     name
@@ -14,6 +15,27 @@ export const PRODUCT_DETAILED_FRAGMENT = gql`
     minimumPrice
     specialPrice
     isSaleable
+    images {
+      edges{
+        node {
+          id
+          path
+          publicPath
+          position
+        }
+      }
+    }
+    productOptions
+    flexibleVariants {
+      id
+      _id
+      sku
+      variantImages
+      optionValues
+      quantity
+      price
+      priceIndices
+    }
     variants {
       edges {
         node {
@@ -23,10 +45,16 @@ export const PRODUCT_DETAILED_FRAGMENT = gql`
         }
       }
     }
-      reviews {
+    reviews {
       edges {
         node {
+          id
+          _id
           rating
+          title
+          name
+          comment
+          createdAt
         }
       }
     }

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

@@ -1,4 +1,4 @@
-import { SingleProductResponse } from "@/app/(public)/product/[...urlProduct]/page";
+import { SingleProductResponse } from "@/components/catalog/type";
 import { GET_PRODUCT_SWATCH_REVIEW } from "@/graphql";
 import { cachedProductRequest } from "@/utils/hooks/useCache";