fogwind hai 2 semanas
pai
achega
7b8ca13c22
Modificáronse 100 ficheiros con 5403 adicións e 3315 borrados
  1. 11 0
      .env.example
  2. 3 1
      .gitignore
  3. 1 0
      .npmrc
  4. 114 2
      README.md
  5. 2 2
      hero.ts
  6. 14 4
      package.json
  7. 1870 2190
      pnpm-lock.yaml
  8. BIN=BIN
      public/image/logo.png
  9. BIN=BIN
      public/image/navigate/202309040910.png
  10. BIN=BIN
      public/image/navigate/202309040911.png
  11. BIN=BIN
      public/image/navigate/202309040912.png
  12. BIN=BIN
      public/image/navigate/202309040913.png
  13. 6 6
      src/app/(public)/search/[collection]/page.tsx
  14. 174 0
      src/app/(public)/customer/account/edit/page.tsx
  15. 248 0
      src/app/(public)/customer/account/page.tsx
  16. 172 0
      src/app/(public)/customer/address/new/page.tsx
  17. 100 0
      src/app/(public)/customer/address/page.tsx
  18. 4 3
      src/app/(public)/page.tsx
  19. 67 83
      src/app/(public)/product/[...urlProduct]/page.tsx
  20. 30 0
      src/app/(public)/product/_components/ProductAddToCart.tsx
  21. 18 0
      src/app/(public)/product/_components/ProductDetail.tsx
  22. 298 0
      src/app/(public)/product/_components/ProductInformation.tsx
  23. 58 0
      src/app/(public)/product/_components/ProductMedia.tsx
  24. 32 0
      src/app/(public)/product/_components/ProductReviewSection.tsx
  25. 18 0
      src/app/(public)/product/_components/ProductShortDescription.tsx
  26. 278 0
      src/app/(public)/product/_components/review/AddProductReview.tsx
  27. 68 0
      src/app/(public)/product/_components/review/AddRatingStar.tsx
  28. 17 0
      src/app/(public)/product/_components/review/NoReview.tsx
  29. 47 0
      src/app/(public)/product/_components/review/ReviewAdd.tsx
  30. 200 0
      src/app/(public)/product/_components/review/ReviewDetail.tsx
  31. 7 8
      src/app/(public)/search/page.tsx
  32. 98 62
      src/app/api/graphql/route.ts
  33. 54 47
      src/app/globals.css
  34. 3 0
      src/app/layout.tsx
  35. 1 1
      src/app/robots.ts
  36. 16 0
      src/components/Portal.tsx
  37. 10 174
      src/components/cart/CartModal.tsx
  38. 1 1
      src/components/cart/OrderDetail.tsx
  39. 3 0
      src/components/catalog/product/ProductCard.tsx
  40. 2 2
      src/components/catalog/product/RelatedProductsSection.tsx
  41. 1 1
      src/components/catalog/review/AddProductReview.tsx
  42. 2 1
      src/components/catalog/review/ReviewDetail.tsx
  43. 1 1
      src/components/catalog/review/ReviewSection.tsx
  44. 70 0
      src/components/catalog/type.ts
  45. 2 1
      src/components/checkout/stepper/payment/PaymentMethod.tsx
  46. 3 2
      src/components/checkout/stepper/payment/index.tsx
  47. 6 4
      src/components/checkout/stepper/shipping/ShippingMethod.tsx
  48. 3 3
      src/components/checkout/stepper/shipping/index.tsx
  49. 1 1
      src/components/checkout/type.ts
  50. 312 0
      src/components/common/AddToCartModal/AddToCartModal.tsx
  51. 12 0
      src/components/common/AddToCartModal/AddToCartModalWrapper.tsx
  52. 40 0
      src/components/common/AddToCartModal/FooterBtnInAddToCartModal.tsx
  53. 27 0
      src/components/common/AddToCartModal/ImgSwiperInAddToCartModal.tsx
  54. 61 0
      src/components/common/AddToCartModal/OpenAddToCartModalButton.tsx
  55. 63 0
      src/components/common/AddToCartModal/ProductOptionsInAddToCartModal.tsx
  56. 24 10
      src/components/common/LoadingSpinner.tsx
  57. 1 1
      src/components/common/button/ReviewButton.tsx
  58. 11 37
      src/components/common/icons/LogoIcon.tsx
  59. 3 3
      src/components/common/slider/HeroCarouselShimmer.tsx
  60. 13 6
      src/components/customer/LoginForm.tsx
  61. 9 0
      src/components/customer/RegistrationForm.tsx
  62. 90 0
      src/components/customer/SettingModal.tsx
  63. 83 0
      src/components/customer/accountSwiper.tsx
  64. 28 58
      src/components/customer/credentials/CredentialModal.tsx
  65. 3 3
      src/components/home/CategoryCarousel.tsx
  66. 1 1
      src/components/home/ImageCarousel.tsx
  67. 1 1
      src/components/home/ProductCarousel.tsx
  68. 0 105
      src/components/layout/navbar/BottomNavbar.tsx
  69. 5 8
      src/components/layout/navbar/CartAndUserActions.tsx
  70. 0 55
      src/components/layout/navbar/CategoriesMenu.tsx
  71. 11 19
      src/components/layout/navbar/MobileMenu.tsx
  72. 27 0
      src/components/layout/navbar/MobileMenuTrigger.tsx
  73. 41 8
      src/components/layout/navbar/index.tsx
  74. 0 57
      src/components/theme/category-carousel/Category.tsx
  75. 4 3
      src/components/theme/filters/MobileFilter.tsx
  76. 4 4
      src/components/theme/filters/SortOrder.tsx
  77. 0 78
      src/components/theme/theme-switch/ThemeSwitch.tsx
  78. 0 13
      src/components/theme/theme-switch/index.tsx
  79. 6 1
      src/graphql/cart/mutations/AddProductToCart.ts
  80. 7 2
      src/graphql/cart/mutations/CreateMergeCart.ts
  81. 6 2
      src/graphql/cart/mutations/UpdateCartItems.ts
  82. 29 1
      src/graphql/catalog/fragments/ProductDetailed.ts
  83. 2 0
      src/graphql/checkout/mutations/CreateCheckoutOrder.ts
  84. 1 0
      src/graphql/checkout/queries/GetCheckoutPaymentMethods.ts
  85. 1 1
      src/graphql/index.ts
  86. 79 0
      src/lib/ApolloClientBrowser.ts
  87. 47 0
      src/lib/ApolloClientServer.ts
  88. 0 103
      src/lib/apollo-client.ts
  89. 97 52
      src/lib/graphql-fetch.ts
  90. 55 0
      src/lib/restApiClient.ts
  91. 4 4
      src/providers/ApolloWrapper.tsx
  92. 3 11
      src/providers/ThemeProvider.tsx
  93. 1 1
      src/proxy.ts
  94. 34 0
      src/store/slices/addToCartDialogSlice.ts
  95. 2 2
      src/store/slices/cart-slice.ts
  96. 2 0
      src/store/store.ts
  97. 14 0
      src/types/cart/type.ts
  98. 5 8
      src/types/checkout/type.ts
  99. 0 57
      src/utils/LRUCache.ts
  100. 0 0
      src/utils/actions.ts

+ 11 - 0
.env.example

@@ -0,0 +1,11 @@
+# Bagisto API Configuration
+NEXT_PUBLIC_BAGISTO_ENDPOINT=https://your-bagisto-instance.com
+NEXT_PUBLIC_BAGISTO_STOREFRONT_KEY=your_storefront_key_here
+
+# NextAuth Configuration
+NEXTAUTH_URL=http://localhost:3001
+# NextAuth 使用 secret(下面的值)对 session中的token 对象进行 签名 + 加密,生成一个 JWT 字符串,解密这个JWT字符串就可以还原token对象。
+NEXTAUTH_SECRET=9jstKgQ1Q22PlDDhPtXTZ6G4MEzHGDJIkebcd/5UyZ4=
+
+# Application Settings
+COMPANY_NAME=Your Company Name

+ 3 - 1
.gitignore

@@ -31,7 +31,9 @@ yarn-error.log*
 .pnpm-debug.log*
 
 # env files (can opt-in for committing if needed)
-.env*
+.env
+.env.production
+.env.local
 
 # vercel
 .vercel

+ 1 - 0
.npmrc

@@ -0,0 +1 @@
+public-hoist-pattern[]=*@heroui/*

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 114 - 2
README.md


+ 2 - 2
hero.ts

@@ -1,5 +1,5 @@
 // hero.ts
-import { heroui } from "@heroui/react";
+// import { heroui } from "@heroui/react";
 // or import from theme package if you are using individual packages.
-// import { heroui } from "@heroui/theme";
+import { heroui } from "@heroui/theme";
 export default heroui();

+ 14 - 4
package.json

@@ -11,17 +11,27 @@
     "package-version": "npx npm-check-updates"
   },
   "dependencies": {
-    "@apollo/client": "^3.14.0",
+    "@apollo/client": "^4.1.9",
+    "@apollo/client-integration-nextjs": "^0.14.5",
     "@heroicons/react": "^2.2.0",
     "@heroui/accordion": "^2.2.25",
     "@heroui/alert": "^2.2.32",
     "@heroui/avatar": "^2.2.26",
+    "@heroui/button": "^2.2.32",
+    "@heroui/date-picker": "^2.3.33",
     "@heroui/drawer": "^2.2.25",
+    "@heroui/input": "^2.4.33",
+    "@heroui/modal": "^2.2.29",
     "@heroui/popover": "^2.3.32",
-    "@heroui/react": "^2.8.6",
+    "@heroui/radio": "^2.3.32",
     "@heroui/select": "^2.4.29",
     "@heroui/switch": "^2.2.27",
     "@heroui/system": "^2.4.28",
+    "@heroui/theme": "^2.4.26",
+    "@heroui/tooltip": "^2.2.29",
+    "@heroui/use-disclosure": "^2.2.19",
+    "@internationalized/date": "3.12.0",
+    "@paypal/react-paypal-js": "^9.2.0",
     "@react-aria/visually-hidden": "^3.8.31",
     "@react-types/shared": "^3.33.1",
     "@reduxjs/toolkit": "^2.10.1",
@@ -29,13 +39,13 @@
     "framer-motion": "^12.23.24",
     "graphql": "^16.12.0",
     "lucide-react": "^0.563.0",
-    "next": "16.2.3",
+    "next": "16.2.6",
     "next-auth": "^4.24.13",
-    "next-themes": "^0.4.6",
     "react": "19.2.5",
     "react-dom": "19.2.5",
     "react-hook-form": "^7.66.1",
     "react-redux": "^9.2.0",
+    "rxjs": "^7.8.2",
     "swiper": "^12.1.3"
   },
   "devDependencies": {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 1870 - 2190
pnpm-lock.yaml


BIN=BIN
public/image/logo.png


BIN=BIN
public/image/navigate/202309040910.png


BIN=BIN
public/image/navigate/202309040911.png


BIN=BIN
public/image/navigate/202309040912.png


BIN=BIN
public/image/navigate/202309040913.png


+ 6 - 6
src/app/(public)/search/[collection]/page.tsx

@@ -14,14 +14,14 @@ import {
   GET_FILTER_PRODUCTS,
   GET_TREE_CATEGORIES,
 } from "@/graphql";
-import { cachedGraphQLRequest, cachedCategoryRequest } from "@/utils/hooks/useCache";
+import { cachedGraphQLRequest, cachedCategoryRequest, getFilterAttributes } from "@/utils/hooks/useCache";
 import { SortByFields } from "@utils/constants";
 import { CategoryDetail } from "@components/theme/search/CategoryDetail";
 import { Suspense } from "react";
 import FilterListSkeleton from "@components/common/skeleton/FilterSkeleton";
 import { TreeCategoriesResponse } from "@/types/theme/category-tree";
 import { MobileSearchBar } from "@components/layout/navbar/MobileSearch";
-import { extractNumericId, findCategoryBySlug, getFilterAttributes, buildProductFilters } from "@utils/helper";
+import { extractNumericId, findCategoryBySlug, buildProductFilters } from "@utils/helper";
 
 /**列表页 */
 export async function generateMetadata({
@@ -31,7 +31,7 @@ export async function generateMetadata({
 }): Promise<Metadata> {
   const { collection: categorySlug } = await params;
 
-  const treeData = await cachedGraphQLRequest<TreeCategoriesResponse>(
+  const {data:treeData} = await cachedGraphQLRequest<TreeCategoriesResponse>(
     "category",
     GET_TREE_CATEGORIES,
     { parentId: 1 }
@@ -60,7 +60,7 @@ export default async function CategoryPage({
   const { collection: categorySlug } = await params;
   const resolvedParams = await searchParams;
 
-  const [treeData, filterAttributes] = await Promise.all([
+  const [{data:treeData}, filterAttributes] = await Promise.all([
     cachedGraphQLRequest<TreeCategoriesResponse>(
       "category",
       GET_TREE_CATEGORIES,
@@ -103,7 +103,7 @@ export default async function CategoryPage({
 
   const filterInput = JSON.stringify(filterObject);
   
-  const [data] = await Promise.all([
+  const [{data}] = await Promise.all([
     cachedCategoryRequest<ProductsResponse>(
       categorySlug,
       GET_FILTER_PRODUCTS,
@@ -121,7 +121,7 @@ export default async function CategoryPage({
 
   const products = data?.products?.edges?.map((e) => e.node) || [];
   const pageInfo = data?.products?.pageInfo;
-  const totalCount = data?.products?.totalCount;
+  const totalCount = data?.products?.totalCount || 0;
   const translation = categoryItem.translation;
 
   return (

+ 174 - 0
src/app/(public)/customer/account/edit/page.tsx

@@ -0,0 +1,174 @@
+"use client";
+import React from "react";
+import { useState } from "react";
+import { useEffect } from "react";
+import {
+  parseDate,
+  getLocalTimeZone,
+  CalendarDate,
+} from "@internationalized/date";
+import { DatePicker } from "@heroui/date-picker";
+const AccountEditPage = () => {
+  // 统一表单对象
+  const [form, setForm] = useState({
+    firstname: "",
+    lastname: "",
+    date: parseDate("2026-05-12"),
+    email: "",
+    current_password: "",
+    password: "",
+  });
+
+  // 统一处理所有input变化
+  const handleChange = (e) => {
+    const { name, value } = e.target;
+    setForm((prev) => ({
+      ...prev,
+      [name]: value,
+    }));
+  };
+  // 1. 定义状态:控制修改密码显示隐藏
+  const [showContent, setShowContent] = useState(false);
+  //   日期选择方法
+  const handleDateChange = (dateValue: CalendarDate | null) => {
+    if (!dateValue) return;
+
+    // 把 CalendarDate 转成 标准字符串:YYYY-MM-DD
+    const formattedDate = `${dateValue.year}-${String(dateValue.month).padStart(2, "0")}-${String(dateValue.day).padStart(2, "0")}`;
+
+    setForm((prev) => ({
+      ...prev,
+      date: formattedDate, // 现在是正常字符串了
+    }));
+  };
+  // 勾选框改变时:隐藏时 → 清空密码
+  const handleCheckChange = (e) => {
+    const isChecked = e.target.checked;
+    setShowContent(isChecked);
+
+    // ✅ 关键:不勾选时清空密码字段
+    if (!isChecked) {
+      setForm((prev) => ({
+        ...prev,
+        current_password: "",
+        password: "",
+      }));
+    }
+  };
+  // 统一提交
+  const handleSubmit = (e) => {
+    e.preventDefault();
+    // 直接拿form所有数据
+    console.log("表单数据:", form);
+  };
+  useEffect(() => {
+    // console.log("form 已更新 →", form);
+  }, [form]);
+  return (
+    <div className="w-full h-full">
+      <form className="overflow-hidden" onSubmit={handleSubmit}>
+        <div className="bg-[#fff] pr-2.5 pl-2.5 w-full text-center text-base h-11 leading-11 font-semibold relative text-[#0b0b0b]">
+          <a href="/customer/account" className="absolute left-2.5 text-2xl inline-block top-1/2  -translate-y-1/2">
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="24"
+              height="24"
+              viewBox="0 0 24 24"
+              fill="none"
+            >
+              <path
+                stroke="rgba(0, 0, 0, 1)"
+                stroke-width="1.2"
+                stroke-linejoin="round"
+                stroke-linecap="round"
+                d="M15 18L9 12L15 6"
+              ></path>
+            </svg>
+          </a>
+          <span className="vipReturnTitles">Account Information</span>
+        </div>
+        <input name="form_key" type="hidden" value="kW7wucXHATeeem75"></input>
+        <div className="bg-[#f8f8f8] p-[20px_10px]">
+          <div className="text-start">*Name</div>
+          <div className="flex justify-between items-center">
+            <input
+              name="firstname" // 必须和state字段对应
+              value={form.firstname}
+              onChange={handleChange}
+              placeholder="firstname"
+              className="w-43 h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal"
+            />
+            <input
+              name="lastname" // 必须和state字段对应
+              value={form.lastname}
+              onChange={handleChange}
+              placeholder="lastname"
+              className="w-43 h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          <div className="mt-7.5">
+            <DatePicker
+              size="base"
+              onChange={handleDateChange}
+              radius="sm"
+              inputClassName="w-full h-[44px] bg-[#fff]"
+              className="bg-[#fff]"
+              labelPlacement={"outside"}
+              showMonthAndYearPickers
+              aria-label="date picker"
+            />
+            {/* label={"Date of Birth"} */}
+          </div>
+          <div className="mt-7.5">
+            <label htmlFor="email">Email Address*</label>
+            <input
+              name="email" // 必须和state字段对应
+              value={form.email}
+              onChange={handleChange}
+              placeholder="email"
+              className="w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          {/* 选择框(开关)*/}
+          <label className="flex items-center gap-2 cursor-pointer mt-7.5">
+            <input
+              type="checkbox"
+              checked={showContent}
+              onChange={handleCheckChange}
+              className="w-5 h-5"
+            />
+            <span className="text-base">Change Password</span>
+          </label>
+          {showContent && (
+            <div>
+              <input
+                type="password"
+                name="current_password" // name对应key
+                value={form.current_password}
+                onChange={handleChange}
+                placeholder="Old password"
+                className="mt-7.5 w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+              />
+              <input
+                type="password"
+                name="password" // name对应key
+                value={form.password}
+                onChange={handleChange}
+                placeholder="* New Password"
+                className=" mt-7.5 w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+              />
+            </div>
+          )}
+          <button
+            className="w-full h-11 leading-11 bg-[#1bbc9b] text-sm font-normal text-center mt-7.5"
+            type="submit"
+          >
+            Save Changes
+          </button>
+        </div>
+      </form>
+    </div>
+  );
+};
+
+export default AccountEditPage;

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 248 - 0
src/app/(public)/customer/account/page.tsx


+ 172 - 0
src/app/(public)/customer/address/new/page.tsx

@@ -0,0 +1,172 @@
+"use client";
+import React from "react";
+import { useState } from "react";
+import { useEffect } from "react";
+const NewAddressPage = () => {
+  // 统一表单对象
+  const [form, setForm] = useState({
+    firstname: "",
+    lastname: "",
+    email: "",
+    postcode: "",
+    street: "",
+    city: "",
+    default_billing: 0, // 账单地址默认
+    default_shipping: 0, // 收货地址默认
+  });
+  // 统一处理所有input变化
+  const handleChange = (e) => {
+    const { name, value } = e.target;
+    setForm((prev) => ({
+      ...prev,
+      [name]: value,
+    }));
+  };
+  // 统一提交
+  const handleSubmit = (e) => {
+    e.preventDefault();
+    const submitData = {
+      ...form,
+      street: [form.street], // 👈 自动变成数组,后端就能收到 street[]
+    };
+
+    // 直接拿form所有数据
+    console.log("表单数据:", form);
+  };
+  // ========== 账单地址勾选框 ==========
+  const handleBillingChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setForm((prev) => ({
+      ...prev,
+      default_billing: e.target.checked ? 1 : 0,
+    }));
+  };
+
+  // ========== 收货地址勾选框 ==========
+  const handleShippingChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    setForm((prev) => ({
+      ...prev,
+      default_shipping: e.target.checked ? 1 : 0,
+    }));
+  };
+  return (
+    <div className="w-full h-full">
+      <form className="overflow-hidden" onSubmit={handleSubmit}>
+        <div className="bg-[#fff] pr-2.5 pl-2.5 w-full text-center text-base h-11 leading-11 font-semibold relative text-[#0b0b0b]">
+          <div
+            onClick={() => window.history.back()}
+            className="absolute left-2.5 text-2xl inline-block top-1/2  -translate-y-1/2"
+          >
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="24"
+              height="24"
+              viewBox="0 0 24 24"
+              fill="none"
+            >
+              <path
+                stroke="rgba(0, 0, 0, 1)"
+                stroke-width="1.2"
+                stroke-linejoin="round"
+                stroke-linecap="round"
+                d="M15 18L9 12L15 6"
+              ></path>
+            </svg>
+          </div>
+          <span className="vipReturnTitles">Add New Address</span>
+        </div>
+        <input name="form_key" type="hidden" value="kW7wucXHATeeem75"></input>
+        <div className="bg-[#f8f8f8] p-[20px_10px]">
+          <div className="text-start">*Name</div>
+          <div className="flex justify-between items-center">
+            <input
+              name="firstname" // 必须和state字段对应
+              value={form.firstname}
+              onChange={handleChange}
+              placeholder="firstname"
+              className="w-43 h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal"
+            />
+            <input
+              name="lastname" // 必须和state字段对应
+              value={form.lastname}
+              onChange={handleChange}
+              placeholder="lastname"
+              className="w-43 h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+
+          <div className="mt-7.5">
+            <label htmlFor="email">Email Address*</label>
+            <input
+              name="email" // 必须和state字段对应
+              value={form.email}
+              onChange={handleChange}
+              placeholder="email"
+              className="w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          <div className="mt-7.5">
+            <label htmlFor="postcode">Zip/Postal Code</label>
+            <input
+              name="postcode" // 必须和state字段对应
+              value={form.postcode}
+              onChange={handleChange}
+              placeholder="Zip/Postal Code"
+              className="w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          <div className="mt-7.5">
+            <label htmlFor="street">Street Address</label>
+            <input
+              name="street" // 必须和state字段对应
+              value={form.street}
+              onChange={handleChange}
+              placeholder="Street Address"
+              className="w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          <div className="mt-7.5">
+            <label htmlFor="city">City</label>
+            <input
+              name="city" // 必须和state字段对应
+              value={form.city}
+              onChange={handleChange}
+              placeholder="City"
+              className="w-full h-11 rounded-sm border border-solid border-[rgba(102,102,102,1)] indent-4 text-[#a6a6a6] text-base leading-11 font-normal box-[unset]"
+            />
+          </div>
+          {/* 选择框(开关)*/}
+          <label className="flex items-center gap-2 cursor-pointer mt-7.5">
+            <input
+              type="checkbox"
+              checked={form.default_billing === 1}
+              onChange={handleBillingChange}
+              name="default_billing"
+              className="w-5 h-5"
+            />
+            <span className="text-base">Use as my default billing address</span>
+          </label>
+          {/* 选择框(开关)*/}
+          <label className="flex items-center gap-2 cursor-pointer mt-7.5">
+            <input
+              type="checkbox"
+              checked={form.default_shipping === 1}
+              onChange={handleShippingChange}
+              name="default_shipping"
+              className="w-5 h-5"
+            />
+            <span className="text-base">
+              Use as my default shipping address
+            </span>
+          </label>
+          <button
+            className="w-full h-11 leading-11 bg-[#1bbc9b] text-sm font-normal text-center mt-7.5"
+            type="submit"
+          >
+            Save Address
+          </button>
+        </div>
+      </form>
+    </div>
+  );
+};
+export default NewAddressPage;

+ 100 - 0
src/app/(public)/customer/address/page.tsx

@@ -0,0 +1,100 @@
+"use client";
+import React from "react";
+import { useState } from "react";
+import { useEffect } from "react";
+const CustomerAddressPage = () => {
+  // 模拟地址数据(可根据实际需求替换)
+  const addressData = {
+    billing: {
+      name: "zhang xxxx",
+      line1: "ddddd",
+      line2: "aaaaaaaa",
+      cityStateZip: "cscsc, Alabama, 90605",
+      country: "United States",
+      phone: "2123165641",
+    },
+    shipping: {
+      name: "zhang xxxx",
+      line1: "ddddd",
+      line2: "aaaaaaaa",
+      cityStateZip: "cscsc, Alabama, 90605",
+      country: "United States",
+      phone: "2123165641",
+    },
+  };
+  return (
+    <div className="w-full h-full">
+      <div className="bg-[#fff] pr-2.5 pl-2.5 w-full text-center text-base h-11 leading-11 font-semibold relative text-[#0b0b0b]">
+        <div
+          onClick={() => window.history.back()}
+          className="absolute left-2.5 text-2xl inline-block top-1/2  -translate-y-1/2"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            width="24"
+            height="24"
+            viewBox="0 0 24 24"
+            fill="none"
+          >
+            <path
+              stroke="rgba(0, 0, 0, 1)"
+              stroke-width="1.2"
+              stroke-linejoin="round"
+              stroke-linecap="round"
+              d="M15 18L9 12L15 6"
+            ></path>
+          </svg>
+        </div>
+        <span className="vipReturnTitles">SHIPPING ADDRESS</span>
+      </div>
+      <div className="max-w-md mx-auto p-4 bg-[#f8f8f8]">
+        {/* 账单地址模块 */}
+        <div className="mb-6">
+          <h2 className="text-xl font-bold uppercase mb-3">
+            Default Billing Address
+          </h2>
+          <div className="border border-gray-200 p-4">
+            <p className="mb-1">{addressData.billing.name}</p>
+            <p className="mb-1">{addressData.billing.line1}</p>
+            <p className="mb-1">{addressData.billing.line2}</p>
+            <p className="mb-1">{addressData.billing.cityStateZip}</p>
+            <p className="mb-1">{addressData.billing.country}</p>
+            <p className="mb-4">T: {addressData.billing.phone}</p>
+            <div className="text-right">
+              <a
+                href="#"
+                className="text-blue-600 underline hover:text-blue-800"
+              >
+                Change Billing Address
+              </a>
+            </div>
+          </div>
+        </div>
+
+        {/* 收货地址模块 */}
+        <div>
+          <h2 className="text-xl font-bold uppercase mb-3">
+            Default Shipping Address
+          </h2>
+          <div className="border border-gray-200 p-4">
+            <p className="mb-1">{addressData.shipping.name}</p>
+            <p className="mb-1">{addressData.shipping.line1}</p>
+            <p className="mb-1">{addressData.shipping.line2}</p>
+            <p className="mb-1">{addressData.shipping.cityStateZip}</p>
+            <p className="mb-1">{addressData.shipping.country}</p>
+            <p className="mb-4">T: {addressData.shipping.phone}</p>
+            <div className="text-right">
+              <a
+                href="#"
+                className="text-blue-600 underline hover:text-blue-800"
+              >
+                Change Shipping Address
+              </a>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  );
+};
+export default CustomerAddressPage;

+ 4 - 3
src/app/(public)/page.tsx

@@ -3,12 +3,12 @@ import RenderThemeCustomization from "@components/home/RenderThemeCustomization"
 import HomeImageBanner from "@components/home/HomeImageBanner";
 import { ThemeCustomizationResponse } from "@/types/theme/theme-customization";
 import { cachedGraphQLRequest } from "@/utils/hooks/useCache";
-
+import {DatePicker} from "@heroui/date-picker";
 export const revalidate = 3600;
 
 export default async function Home() {
   // const 
-  const data = await cachedGraphQLRequest<ThemeCustomizationResponse>(
+  const {data} = await cachedGraphQLRequest<ThemeCustomizationResponse>(
     "home",
     GET_THEME_CUSTOMIZATION,
     { first: 20 }
@@ -17,7 +17,8 @@ export default async function Home() {
   return (
     <>
       <HomeImageBanner />
-      <RenderThemeCustomization themeCustomizations={data?.themeCustomizations} />
+      <DatePicker label={"Birth date"} labelPlacement={"outside"} />
+      <RenderThemeCustomization themeCustomizations={data?.themeCustomizations ?? {edges: []}} />
     </>
     
   );

+ 67 - 83
src/app/(public)/product/[...urlProduct]/page.tsx

@@ -1,60 +1,49 @@
 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";
+  ProductOption,
+  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";
+import {
+    formatFlexibleVariants
+} from "@/utils/variantTools";
 
-const productCache = new LRUCache<ProductNode>(100, 10);
-export const dynamic = "force-static";
-
-export interface SingleProductResponse {
-  product: ProductNode;
-}
-
-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>(
+    const {data:dataById} = await cachedProductRequest<SingleProductResponse>(
       urlKey, // 产品名称
       GET_PRODUCT_BY_URL_KEY, // gql查询语句
       { urlKey: urlKey },
     );
 
     const product = dataById?.product || null;
-    if (product) {
-      productCache.set(urlKey, product);
-    }
+
     return product;
   } catch (error) {
     if (error instanceof Error) {
@@ -80,7 +69,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 +80,59 @@ export default async function ProductPage({
     sku: product?.sku,
   };
 
+  const mediaImgs = (product?.images?.edges ?? []).map((edge: { node: ProductMediaType }) => {
+    return edge.node;
+  });
+  const productOptions: ProductOption[] = product?.productOptions ? JSON.parse(product?.productOptions) : [];
+
+  const flexibleVariants = formatFlexibleVariants(product?.flexibleVariants);
+
     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 key={product._id}
+                    productId={product._id}
+                    name={product?.name || ''}
+                    productOptions={productOptions}
+                    flexibleVariants={flexibleVariants}
+                    isSaleable={product.isSaleable}
+                />
+                <ProductShortDescription shortDescription={product.shortDescription || ''} />
+                <ProductDetail detail={product.description || ''} />
+
+                <ProductReviewSection productId={String(product._id)} />
+            </Suspense>
         </div>
-      </div>
-      <Suspense fallback={<RelatedProductSkeleton />}>
-        <RelatedProductsSection fullPath={fullPath} />
-      </Suspense>
+
+        <Suspense fallback={<RelatedProductSkeleton />}>
+            <RelatedProductsSection fullPath={fullPath} />
+        </Suspense>
     </>
   );
 }

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

@@ -0,0 +1,30 @@
+"use client";
+
+import clsx from "clsx";
+
+export function ProductAddToCart({
+    isAvailable,
+    isLoading,
+    onAddToCart,
+    onBuyNow
+}: { 
+    isAvailable: boolean; 
+    isLoading: boolean;
+    onAddToCart: () => void;
+    onBuyNow: () => void;
+}) { 
+    const btnClass = 'flex-initial flex items-center justify-center w-40 h-ly-46 rounded-4xl text-ly-16 font-bold text-white';
+    return (
+        <div className="fixed w-full bottom-0 left-0 bg-white box-border pt-3 pl-3 pr-3 pb-5 border-t border-solid border-ly-lightgray z-1000">
+            {isAvailable ? (
+                <div className="flex justify-between">
+                    <button onClick={onBuyNow} type="button" className={clsx(btnClass,'bg-ly-deepgreen', { 'opacity-25':isLoading})}>Buy Now</button>
+                    <button onClick={onAddToCart} type="button" className={clsx(btnClass,'bg-ly-middlegreen', { 'opacity-25':isLoading})}>Add To Cart</button>
+                </div>
+            ): (
+                <p>The selected options are not available!</p>
+            )}
+            
+        </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>
+            
+        </>
+
+    );
+}

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

@@ -0,0 +1,298 @@
+"use client";
+
+import { useState, useMemo } 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 { useAddProduct } from "@utils/hooks/useAddToCart";
+import { redirect, RedirectType } from 'next/navigation'
+import {
+    isValueAvailable,
+    isOptionValueAvailable,
+    getFirstAvailable,
+    getAvailableVariants,
+    isCombinationAvailable,
+    formatFlexibleVariants,
+    findNearestAvailableVariant
+} from "@/utils/variantTools";
+import { Price } from "@components/theme/ui/Price";
+
+
+export function ProductInformation({
+    name,
+    productId,
+    productOptions,
+    flexibleVariants,
+    isSaleable
+}: {
+    name: string;
+    productId: number;
+    productOptions: ProductOption[];
+    flexibleVariants: ResolvedVariant[];
+    isSaleable: string | undefined;
+}) {
+    const { isCartLoading, onAddToCart } = useAddProduct();
+    const { showToast } = useCustomToast();
+    
+    // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
+    const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0]?.id );
+
+    const [productQty, setProductQty] = useState(1);
+
+    // 获取所有可用变体(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};
+        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);
+        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 (selectedIds.length === 0 || variant === undefined) {
+            // 尚未初始化,返回一个安全占位对象
+            return { variant: null, totalLinePrice: 0, totalNowPrice: 0, save: 0 };
+        }
+        
+        if(variant.priceIndices && variant.priceIndices.length > 0) {
+            totalLinePrice = Number(variant.priceIndices[0].regular_min_price) * productQty;
+            totalNowPrice = Number(variant.priceIndices[0].min_price) * productQty;
+        }
+        if(totalLinePrice !== totalNowPrice) {
+            save = -(totalLinePrice - totalNowPrice); 
+        }
+
+        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);
+        }
+    }
+
+    async function addProductToCart(action:string = 'addtocart') {
+        
+        let params = { 
+            productId: String(productId), 
+            quantity: productQty,
+            variantId: currentVariantInfo.variant?._id,
+        };
+        let res = await onAddToCart(params);
+        console.log('onAddToCart result --- ', res);
+        if(action === 'buynow') {
+            if(res) {
+                const responseData = res.data?.createAddProductInCart?.addProductInCart;
+                if(responseData && responseData.success) {
+                    redirect('/checkout?step=address', RedirectType.push);
+                }
+            }
+        }
+    } 
+
+    let addToCartHandler = async () => {
+   
+        if(!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+
+        if(currentVariantInfo.variant && !isCartLoading) {
+            addProductToCart();
+        }
+    };
+
+    let buyNowHandler = () => {
+        if(!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+        if(currentVariantInfo.variant && !isCartLoading) {
+            addProductToCart('buynow');
+        }
+    };
+
+
+    return (<>
+        <div className="box-border w-full pr-4 pl-4">
+            <h3 className="text-ly-12 mt-4 leading-ly-22">{name} {isSaleable}</h3>
+      
+            <div className="flex items-center mt-2">
+                <Price 
+                    className="text-ly-16 leading-ly-24 font-bold"
+                    amount={String(currentVariantInfo.totalNowPrice)} 
+                    currencyCode="USD"
+                />
+                {
+                    currentVariantInfo.totalLinePrice !== currentVariantInfo.totalNowPrice && (
+                        <>
+                            <Price 
+                                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
+                                amount={String(currentVariantInfo.totalLinePrice)} 
+                                currencyCode="USD"
+                            /><Price 
+                                className="text-ly-12 leading-ly-20 text-ly-gray line-through ml-1"
+                                amount={String(currentVariantInfo.totalLinePrice)} 
+                                currencyCode="USD"
+                            />
+                            <Price 
+                                className="text-ly-12 leading-ly-16 bg-ly-gold pr-1.5 pl-1.5 ml-3"
+                                amount={String(currentVariantInfo.save)} 
+                                currencyCode="USD"
+                            />
+                        </>
+                    )
+                }
+                
+            </div>
+            
+        </div>
+
+        <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} 
+            isLoading={isCartLoading}
+            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/input";
+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 "@/components/catalog/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/modal";
+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>
+    </>
+  );
+}
+
+

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

@@ -0,0 +1,200 @@
+"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 } from "@heroui/avatar";
+import { Tooltip } from "@heroui/tooltip";
+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;

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

@@ -4,10 +4,9 @@ import NotFound from "@/components/theme/search/not-found";
 import { isArray } from "@/utils/type-guards";
 import { GET_FILTER_PRODUCTS } from "@/graphql";
 import { GET_PRODUCTS, GET_PRODUCTS_PAGINATION } from "@/graphql";
-import { cachedGraphQLRequest } from "@/utils/hooks/useCache";
+import { cachedGraphQLRequest, getFilterAttributes } from "@/utils/hooks/useCache";
 import {
   generateMetadataForPage,
-  getFilterAttributes,
   buildProductFilters,
 } from "@/utils/helper";
 import SortOrder from "@/components/theme/filters/SortOrder";
@@ -31,7 +30,7 @@ export async function generateStaticParams() {
     const commonSearches = [""];
     const params = [];
     for (const query of commonSearches) {
-      const data = await cachedGraphQLRequest<ProductsResponse>(
+      const {data} = await cachedGraphQLRequest<ProductsResponse>(
         "search",
         GET_PRODUCTS,
         {
@@ -55,7 +54,7 @@ export async function generateStaticParams() {
         }
         params.push(pageParams);
         if (i < totalPages - 1) {
-          const pageData = await cachedGraphQLRequest<ProductsResponse>(
+          const {data: pageData} = await cachedGraphQLRequest<ProductsResponse>(
             "search",
             GET_PRODUCTS,
             {
@@ -137,9 +136,9 @@ export default async function SearchPage({
     );
   } else {
     dataPromise = (async () => {
-      let currentAfterCursor = afterCursor;
+      let currentAfterCursor: string | undefined = afterCursor;
       if (currentPage > 0 && !afterCursor) {
-        const cursorData = await cachedGraphQLRequest<ProductsResponse>(
+        const {data: cursorData} = await cachedGraphQLRequest<ProductsResponse>(
           "search",
           GET_PRODUCTS_PAGINATION,
           {
@@ -163,14 +162,14 @@ export default async function SearchPage({
     })();
   }
 
-  const [data, filterAttributes] = await Promise.all([
+  const [{data}, filterAttributes] = await Promise.all([
     dataPromise,
     getFilterAttributes(),
   ]);
 
   const products = data?.products?.edges?.map((e) => e.node) || [];
   const pageInfo = data?.products?.pageInfo;
-  const totalCount = data?.products?.totalCount;
+  const totalCount = data?.products?.totalCount || 0;
 
   return (
     <>

+ 98 - 62
src/app/api/graphql/route.ts

@@ -17,6 +17,7 @@ import {
     CREATE_CHECKOUT_PAYMENT_METHODS,
     CREATE_CHECKOUT_ORDER,
     CREATE_PRODUCT_REVIEW,
+    GET_PRODUCT_BY_URL_KEY,
 } from "@/graphql";
 
 const ALLOWED_OPERATIONS: Record<string, any> = {
@@ -35,84 +36,119 @@ const ALLOWED_OPERATIONS: Record<string, any> = {
     CreateCheckoutOrder: CREATE_CHECKOUT_ORDER,
     CreateProductReview: CREATE_PRODUCT_REVIEW,
 };
+const QUERY_OPERATIONS: Record<string, any> = {
+    GetProductById: GET_PRODUCT_BY_URL_KEY,
+}
+
+interface FetchOption  {
+    query: string;
+    variables?: Record<string, any>;
+    cache?: RequestCache;
+    guestToken?: string;
+    operationName?: string;
+}
+
+// 需要authorization的operation
+function authorizationOperations(body: Record<string, any>,req:NextRequest): FetchOption {
+    const { operationName, query: bodyGraphqlQuery, variables } = body;
+    const guestToken = getAuthToken(req);
+    const query = ALLOWED_OPERATIONS[operationName];
+    let finalVariables = variables;
+
+    if (operationName === 'CheckoutPaymentMethods' || operationName === 'CheckoutShippingRates') {
+        finalVariables = { ...variables };
+    }
+
+    if (operationName === 'CreateCheckoutPaymentMethod') {
+        finalVariables = {
+            ...variables,
+            successUrl: variables?.successUrl ?? `payment/success`,
+            failureUrl: variables?.failureUrl ?? `payment/failure`,
+            cancelUrl: variables?.cancelUrl ?? `payment/cancel`
+        };
+    }
+
+    if (operationName === 'createCheckoutAddress' && body.billingFirstName) {
+        finalVariables = {
+            billingFirstName: body.billingFirstName,
+            billingLastName: body.billingLastName,
+            billingEmail: body.billingEmail,
+            billingAddress: body.billingAddress,
+            billingCity: body.billingCity,
+            billingCountry: body.billingCountry,
+            billingState: body.billingState,
+            billingPostcode: body.billingPostcode,
+            billingPhoneNumber: body.billingPhoneNumber,
+            billingCompanyName: body.billingCompanyName,
+            useForShipping: body.useForShipping,
+            ...(!body.useForShipping && {
+                shippingFirstName: body.shippingFirstName,
+                shippingLastName: body.shippingLastName,
+                shippingEmail: body.billingEmail,
+                shippingAddress: body.shippingAddress,
+                shippingCity: body.shippingCity,
+                shippingCountry: body.shippingCountry,
+                shippingState: body.shippingState,
+                shippingPostcode: body.shippingPostcode,
+                shippingPhoneNumber: body.shippingPhoneNumber,
+                shippingCompanyName: body.shippingCompanyName,
+            })
+        };
+    }
+
+    if (operationName === 'createAddProductInCart' && body.productId) {
+        finalVariables = {
+            cartId: body.cartId ?? null,
+            productId: body.productId,
+            quantity: body.quantity,
+        };
+    }
+    return {
+        query,
+        variables: finalVariables,
+        cache: "no-store",
+        guestToken,
+        operationName
+    }
+}
+
+
+// 不需要authorization的operation
+function notAuthorizationOperations(body: Record<string, any>): FetchOption {
+    const { operationName, query: bodyGraphqlQuery, variables } = body;
+    const query = bodyGraphqlQuery;
+    return {
+        query,
+        variables,
+        cache: "no-store",
+        operationName
+    }
+}
 
 export async function POST(req: NextRequest) {
     try {
         const body = await req.json();
-        const { operationName, variables } = body;
-        const guestToken = getAuthToken(req);
+        const { operationName } = body;
 
-        if (!operationName || !ALLOWED_OPERATIONS[operationName]) {
+        if (!operationName) {
             return NextResponse.json(
                 { message: "Invalid or unauthorized operation: " + (operationName || "missing") },
                 { status: 400 }
             );
         }
-
-        const query = ALLOWED_OPERATIONS[operationName];
-
-        let finalVariables = variables;
-
-        if (operationName === 'CheckoutPaymentMethods' || operationName === 'CheckoutShippingRates') {
-            finalVariables = { ...variables };
-        }
-
-        if (operationName === 'CreateCheckoutPaymentMethod') {
-            finalVariables = {
-                ...variables,
-                successUrl: variables?.successUrl ?? `payment/success`,
-                failureUrl: variables?.failureUrl ?? `payment/failure`,
-                cancelUrl: variables?.cancelUrl ?? `payment/cancel`
-            };
-        }
-
-        if (operationName === 'createCheckoutAddress' && body.billingFirstName) {
-            finalVariables = {
-                billingFirstName: body.billingFirstName,
-                billingLastName: body.billingLastName,
-                billingEmail: body.billingEmail,
-                billingAddress: body.billingAddress,
-                billingCity: body.billingCity,
-                billingCountry: body.billingCountry,
-                billingState: body.billingState,
-                billingPostcode: body.billingPostcode,
-                billingPhoneNumber: body.billingPhoneNumber,
-                billingCompanyName: body.billingCompanyName,
-                useForShipping: body.useForShipping,
-                ...(!body.useForShipping && {
-                    shippingFirstName: body.shippingFirstName,
-                    shippingLastName: body.shippingLastName,
-                    shippingEmail: body.billingEmail,
-                    shippingAddress: body.shippingAddress,
-                    shippingCity: body.shippingCity,
-                    shippingCountry: body.shippingCountry,
-                    shippingState: body.shippingState,
-                    shippingPostcode: body.shippingPostcode,
-                    shippingPhoneNumber: body.shippingPhoneNumber,
-                    shippingCompanyName: body.shippingCompanyName,
-                })
-            };
+        let fetchOption: FetchOption = notAuthorizationOperations(body);
+        if(ALLOWED_OPERATIONS[operationName]) {
+            fetchOption = authorizationOperations(body,req);
         }
 
-        if (operationName === 'createAddProductInCart' && body.productId) {
-            finalVariables = {
-                cartId: body.cartId ?? null,
-                productId: body.productId,
-                quantity: body.quantity,
-            };
-        }
-
-        const response = await bagistoFetch<any>({
-            query,
-            variables: finalVariables,
-            cache: "no-store",
-            guestToken,
-        });
-
+        
+        const response = await bagistoFetch<any>(fetchOption);
+        // console.log('response ------ ', response);
         return NextResponse.json({
             data: response.body.data,
         });
     } catch (error) {
+        console.log('response err------ ', error);
         if (isBagistoError(error)) {
             return NextResponse.json(
                 {

+ 54 - 47
src/app/globals.css

@@ -31,30 +31,18 @@ body::-webkit-scrollbar-thumb {
   background-color: #d4d4d4;
 }
 
-@theme {
-  /* Custom breakpoints */
-  --breakpoint-xxs: 375px;
-  --breakpoint-xs: 425px;
-  --breakpoint-xss: 525px;
-  --breakpoint-400: 400px;
-  --breakpoint-sm: 640px;
-  --breakpoint-md: 768px;
-  --breakpoint-lg: 1024px;
-  --breakpoint-xl: 1280px;
-  --breakpoint-2xl: 1610px;
-  --breakpoint-3xl: 1640px;
-  --breakpoint-1366: 1366px;
-  /* Custom spacing */
-  --spacing-7_5: 30px;
-  --spacing-30: 120px;
-  --spacing-92_5: 23.125rem;
-  /* Font sizes */
-  --font-size-xxs: 13px;
-  /* Font family */
-  --font-family-outfit: var(--font-outfit), system-ui, sans-serif;
+/**
+ * 移动端适配核心:clamp() 根字号  在390px设计稿下 16px ≈ 4.1025641vw = 1rem
+ * 比如 390px设计稿,有一个div宽度是120px,那么可以写 w-30,下面是换算原理:
+ * tailwindcss中单位用的是rem, 默认根字号为16px,也就是在tailwindcss中,0.25rem = 4px,
+ * 而对于类名 w-1 来说,这里的1就是0.25rem,即4px,那么w-1就是4px,w-2就是8px,以此类推
+ * 
+ * */
+html {
+  font-size: clamp(10px, 4.1025641vw, 40px);
 }
 
-/* GLOBAL VARIABLES */
+/* GLOBAL VARIABLES :root中定义常规变量,不被映射到工具类*/
 :root {
   /* Font families */
   font-family: var(--font-outfit), system-ui, sans-serif;
@@ -70,27 +58,57 @@ body::-webkit-scrollbar-thumb {
   --selected-bottom-dark: #95b6ff;
   --selected-bg-bottom-dark: #314779;
   --input-color: #b3b3b3;
-  /* font size  */
+
+  /* 字体尺寸 */
   --font-size-xxs: 13px;
-  /* Spacing */
+
+  /* 间距变量(供自定义类使用) */
   --space-30: 120px;
   --space-7_5: 30px;
   --space-92_5: 23.125rem;
+}
+
+/*theme中定义的变量可以被映射到实用程序类(需要注意映射规则,不是所有变量都能映射)*/
+@theme {
+  /* 仅保留字体族定义,其他通过 :root 变量注入 */
+  --font-family-outfit: var(--font-outfit), system-ui, sans-serif;
 
-  /* Screens (breakpoints) */
-  --screen-xxs: 360px;
-  --screen-xs: 425px;
-  --screen-xss: 525px;
-  --screen-400: 400px;
-  --screen-sm: 640px;
-  --screen-md: 768px;
-  --screen-lg: 1024px;
-  --screen-xl: 1280px;
-  --screen-2xl: 1610px;
-  --screen-3xl: 1640px;
-  --screen-1366: 1366px;
+  /*自定义颜色*/
+  --color-ly-lightgray: #f2f2f2;
+  --color-ly-gray: #d3d3d3; /*可被映射为text-ly-gray,bg-ly-gray等相关的颜色类目*/
+  --color-ly-gold: #ffd700;
+  --color-ly-green: #01754a;
+  --color-ly-middlegreen: #036141;
+  --color-ly-deepgreen: #1e3932;
+  /*自定义字体大小 前缀 ly*/
+  --text-ly-12: 0.75rem;
+  --text-ly-14: 0.875rem;
+  --text-ly-16: 1rem;
+  --text-ly-18: 1.125rem;
+  --text-ly-20: 1.25rem;
+  --text-ly-22: 1.375rem;
+  --text-ly-24: 1.5rem;
+  --text-ly-30: 1.875rem; /* 1.875rem (30px) */ 
+	--text-ly-36: 2.25rem; /* 2.25rem (36px) */ 	
+  --text-ly-48: 3rem; /* 3rem (48px) */ 
+
+  /*自定义行高 前缀 ly*/
+  --leading-ly-16: 1rem;
+  --leading-ly-18: 1.125rem;
+  --leading-ly-20: 1.25rem;
+  --leading-ly-22: 1.375rem;
+  --leading-ly-24: 1.5rem;
+  --leading-ly-26: 1.625rem;
+  --leading-ly-28: 1.75rem;
+  --leading-ly-36: 2.25rem;
 }
 
+@utility h-ly-46 {
+  height: 2.875rem;
+}
+@utility strokeline-bg-disabled {
+  background-image:linear-gradient(to bottom right,transparent,transparent calc(50% - 1px),#acacac 50%,#acacac 0,transparent calc(50% + 1px));
+}
 .dark {
   --background: #171717;
   --foreground: #ffffff;
@@ -120,18 +138,13 @@ body {
 }
 
 @layer components {
-
   /* Hide scrollbar for drawer content */
   .drawer-scrollbar-hidden {
     scrollbar-width: none;
-    /* Firefox */
     -ms-overflow-style: none;
-    /* IE and Edge */
   }
-
   .drawer-scrollbar-hidden::-webkit-scrollbar {
     display: none;
-    /* Chrome, Safari, Opera */
   }
 
   .placeholder-input-color::placeholder {
@@ -171,7 +184,6 @@ body {
     0% {
       transform: translateX(-100%);
     }
-
     100% {
       transform: translateX(100%);
     }
@@ -181,11 +193,6 @@ body {
     aspect-ratio: 1.72;
   }
 
-  @media (min-width: 768px) {
-    .image-carousel-aspect {
-      aspect-ratio: 1.98;
-    }
-  }
 
   /* Prevent body scrolling when drawer is open */
   .scroll-locked {

+ 3 - 0
src/app/layout.tsx

@@ -5,6 +5,7 @@ import { generateMetadataForPage } from "@utils/helper";
 import { staticSeo } from "@utils/metadata";
 import { SpeculationRules } from "@components/theme/SpeculationRules";
 import { ErrorBoundary } from "@/components/error/ErrorBoundary";
+import { AddToCartModalWrapper } from "@components/common/AddToCartModal/AddToCartModalWrapper";
 import clsx from "clsx";
 
 // Locale revision marker — required for SSR a11y locale sync (Next.js i18n).
@@ -42,6 +43,7 @@ export default function RootLayout({
   return (
     <html lang="en" suppressHydrationWarning>
       <head>
+        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"></meta>
       </head>
       <body className={clsx(
         "min-h-screen font-outfit text-foreground bg-background antialiased",
@@ -51,6 +53,7 @@ export default function RootLayout({
           <ErrorBoundary>
             <GlobalProviders>
               {children}
+              <AddToCartModalWrapper />
             </GlobalProviders>
             <SpeculationRules />
           </ErrorBoundary>

+ 1 - 1
src/app/robots.ts

@@ -7,6 +7,6 @@ export default function robots(): MetadataRoute.Robots {
       allow: '/',
       disallow:  ['/customer/*', '/checkout'],
     },
-    sitemap: `${process.env.NEXT_PUBLIC_NEXT_AUTH_URL}/sitemap.xml`,
+    sitemap: `${process.env.NEXTAUTH_URL}/sitemap.xml`,
   }
 }

+ 16 - 0
src/components/Portal.tsx

@@ -0,0 +1,16 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+export default function Portal({ children }: { children: React.ReactNode }) {
+  const [mounted, setMounted] = useState(false);
+
+  // useEffect 只会在客户端执行,服务端渲染时不会运行(Next.js 在服务器端会跳过所有 useEffect / useLayoutEffect)。
+  useEffect(() => {
+    setMounted(true); // 组件挂载后,把标志位置为 true
+  }, []); // 空依赖数组,仅执行一次
+
+  if (!mounted) return null; // SSR 时不渲染,避免 hydration 错误
+  return createPortal(children, document.body);
+}

+ 10 - 174
src/components/cart/CartModal.tsx

@@ -1,6 +1,6 @@
 "use client";
 import clsx from "clsx";
-import { useDisclosure } from "@heroui/react";
+import { useDisclosure } from "@heroui/use-disclosure";
 import { AnimatePresence, motion } from "framer-motion";
 import {
   Drawer,
@@ -66,14 +66,14 @@ export default function CartModal({
     ? cartDetail?.cart?.items?.edges
     : [];
   const cartObj: any = cartDetail?.cart ?? {};
-  const isDesktop = useMediaQuery("(min-width: 1024px)");
+
   const mounted = useSyncExternalStore(
     () => () => { },
     () => true,
     () => false,
   );
 
-  useBodyScrollLock(finalIsOpen && !isDesktop);
+  useBodyScrollLock(finalIsOpen);
 
   const handleOpenChange = (open: boolean) => {
     if (!open) {
@@ -100,171 +100,7 @@ export default function CartModal({
         )}
       </button>
 
-      {isDesktop ? (
-        <Drawer
-          backdrop="blur"
-          hideCloseButton={true}
-          classNames={{ backdrop: "bg-white/50 dark:bg-black/50" }}
-          isOpen={finalIsOpen}
-          radius="none"
-          onOpenChange={handleOpenChange}
-        >
-          <DrawerContent>
-            {() => (
-              <>
-                <DrawerHeader className="flex flex-col gap-1">
-                  <div className="flex items-center justify-between">
-                    <p className="text-lg font-semibold">My Cart</p>
-                    <button
-                      aria-label="Close cart"
-                      className="cursor-pointer"
-                      onClick={finalOnClose}
-                    >
-                      <CloseCart />
-                    </button>
-                  </div>
-                </DrawerHeader>
-
-                <DrawerBody className="py-0">
-                  {cart?.length === 0 ? (
-                    <div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
-                      <ShoppingCartIcon className="h-16" />
-                      <p className="mt-6 text-center text-2xl font-bold">
-                        Your cart is empty.
-                      </p>
-                    </div>
-                  ) : (
-                    <div className="flex h-full flex-col justify-between overflow-hidden">
-                      <ul className="my-0 flex-grow overflow-auto py-0">
-                        {Array.isArray(cart) &&
-                          cart?.map((item: any, i: number) => {
-                            const merchandiseSearchParams =
-                              {} as MerchandiseSearchParams;
-                            const merchandiseUrl = createUrl(
-                              `/product/${item?.node.productUrlKey}`,
-                              new URLSearchParams(merchandiseSearchParams),
-                            );
-                            const baseImage: any = safeParse(
-                              item?.node?.baseImage,
-                            );
-
-                            return (
-                              <li key={i} className="flex w-full flex-col">
-                                <div className="flex w-full flex-row justify-between gap-3 px-1 py-4">
-                                  <Link
-                                    className="z-30 flex flex-row space-x-4"
-                                    aria-label={`${item?.node?.name}`}
-                                    href={merchandiseUrl}
-                                    onClick={finalOnClose}
-                                  >
-                                    <div className="relative h-16 w-16 cursor-pointer overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
-                                      <Image
-                                        alt={
-                                          item?.node?.baseImage ||
-                                          item?.product?.name
-                                        }
-                                        className="h-full w-full object-cover"
-                                        height={64}
-                                        src={baseImage?.small_image_url || ""}
-                                        width={74}
-                                        onError={(e) =>
-                                          (e.currentTarget.src = NOT_IMAGE)
-                                        }
-                                      />
-                                    </div>
-
-                                    <div className="flex flex-1 flex-col text-base">
-                                      <span className="line-clamp-1 font-outfit text-base font-medium">
-                                        {item?.node?.name}
-                                      </span>
-                                      {item.name !== DEFAULT_OPTION && (
-                                        <p className="text-sm lowercase line-clamp-1 text-black dark:text-neutral-400">
-                                          {item?.node?.sku}
-                                        </p>
-                                      )}
-                                    </div>
-                                  </Link>
-
-                                  <div className="flex h-16 flex-col justify-between">
-                                    <Price
-                                      amount={item?.node?.price}
-                                      className="flex justify-end space-y-2 text-right font-outfit text-base font-medium"
-                                      currencyCode={"USD"}
-                                    />
-                                    <div className="flex items-center gap-x-2">
-                                      <DeleteItemButton item={item} />
-                                      <div className="ml-auto flex h-9 flex-row items-center rounded-full border border-neutral-200 dark:border-neutral-700">
-                                        <EditItemQuantityButton
-                                          item={item}
-                                          type="minus"
-                                        />
-                                        <p className="w-6 text-center">
-                                          <span className="w-full text-sm">
-                                            {item?.node?.quantity}
-                                          </span>
-                                        </p>
-                                        <EditItemQuantityButton
-                                          item={item}
-                                          type="plus"
-                                        />
-                                      </div>
-                                    </div>
-                                  </div>
-                                </div>
-                              </li>
-                            );
-                          })}
-                      </ul>
-
-                      <div className="border-0 border-t border-solid border-neutral-200 dark:border-dark-grey py-4 text-sm text-neutral-500 dark:text-neutral-400">
-                        {(cartDetail as any)?.cart?.taxAmount > 0 && (
-                          <div className="mb-3 flex items-center justify-between">
-                            <p className="text-base font-normal text-black/[60%] dark:text-white">
-                              Taxes
-                            </p>
-                            <Price
-                              amount={(cartDetail as any)?.cart?.taxAmount}
-                              className="text-right text-base font-medium text-black dark:text-white"
-                              currencyCode={"USD"}
-                            />
-                          </div>
-                        )}
-                        <div className="mb-3 flex items-center justify-between pb-1">
-                          <p className="text-base font-normal text-black/[60%] dark:text-white">
-                            Total
-                          </p>
-                          <Price
-                            amount={(cartDetail as any)?.cart?.grandTotal}
-                            className="text-right text-base font-medium text-black dark:text-white"
-                            currencyCode={"USD"}
-                          />
-                        </div>
-                      </div>
-
-                      <form action={redirectToCheckout}>
-                        <CheckoutButton
-                          cartDetails={cartObj?.items?.edges ?? []}
-                          isGuest={cartObj?.isGuest}
-                          isEmail={
-                            cartObj?.customerEmail ?? getLocalStorage(EMAIL)
-                          }
-                          isSelectShipping={
-                            cartObj?.selectedShippingRate != null
-                          }
-                          isSeclectAddress={isObject(billingAddress)}
-                          isSelectPayment={cartObj?.paymentMethod != null}
-                        />
-                      </form>
-                    </div>
-                  )}
-                </DrawerBody>
-
-                <DrawerFooter className="flex flex-col gap-1" />
-              </>
-            )}
-          </DrawerContent>
-        </Drawer>
-      ) : (
+      
         <AnimatePresence>
           {finalIsOpen && (
             <>
@@ -301,12 +137,12 @@ export default function CartModal({
                     <p
                       className={clsx(
                         "font-semibold",
-                        isDesktop ? "text-lg" : "text-xl",
+                        "text-xl",
                       )}
                     >
                       My Cart
                     </p>
-                    {isDesktop && (
+                 
                       <button
                         aria-label="Close cart"
                         className="cursor-pointer"
@@ -314,14 +150,14 @@ export default function CartModal({
                       >
                         <CloseCart />
                       </button>
-                    )}
+                    
                   </div>
                 </div>
 
                 <div
                   className={clsx(
                     "flex-1 overflow-y-auto px-4 py-0 drawer-scrollbar-hidden",
-                    !isDesktop && "!px-2",
+                    "!px-2",
                   )}
                 >
                   {cart?.length === 0 ? (
@@ -351,7 +187,7 @@ export default function CartModal({
                                 <div
                                   className={clsx(
                                     "flex w-full flex-row justify-between py-4 px-1",
-                                    isDesktop ? "gap-3" : "gap-1 xxs:gap-3",
+                                    "gap-1 xxs:gap-3",
                                   )}
                                 >
                                   <Link
@@ -465,7 +301,7 @@ export default function CartModal({
             </>
           )}
         </AnimatePresence>
-      )}
+      
     </>
   );
 }

+ 1 - 1
src/components/cart/OrderDetail.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { ORDER_ID } from "@/utils/constants";
-import { getCookie } from "@utils/getCartToken";
+import { getCookie } from "@utils/cookie-tools";
 import { useEffect, useState } from "react";
 
 export default function OrderDetail() {

+ 3 - 0
src/components/catalog/product/ProductCard.tsx

@@ -4,6 +4,7 @@ import Grid from "@/components/theme/ui/grid/Grid";
 import AddToCartButton from "@/components/theme/ui/AddToCartButton";
 import { NextImage } from "@/components/common/NextImage";
 import { Price } from "@/components/theme/ui/Price";
+import { OpenAddToCartModalButton } from "@/components/common/AddToCartModal/OpenAddToCartModalButton";
 
 type ProductCardProps = {
   currency: string;
@@ -90,6 +91,8 @@ export const ProductCard: FC<ProductCardProps> = ({
               currencyCode={currency}
             />
           )}
+          <OpenAddToCartModalButton productUrlKey={product.urlKey} />
+
         </div>
       </div>
     </Grid.Item>

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

@@ -1,6 +1,6 @@
 import { GET_RELATED_PRODUCTS } from "@/graphql";
 import { ProductsSection } from "./ProductsSection";
-import { SingleProductResponse } from "@/app/(public)/product/[...urlProduct]/page";
+import { SingleProductResponse } from "@components/catalog/type";
 import { cachedProductRequest } from "@/utils/hooks/useCache";
 
 export async function RelatedProductsSection({
@@ -10,7 +10,7 @@ export async function RelatedProductsSection({
 }) {
     async function getRelatedProduct(urlKey: string) {
       try {
-        const dataById = await cachedProductRequest<SingleProductResponse>(
+        const {data:dataById} = await cachedProductRequest<SingleProductResponse>(
           urlKey,
           GET_RELATED_PRODUCTS,
           {

+ 1 - 1
src/components/catalog/review/AddProductReview.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { useState } from "react";
-import { Textarea } from "@heroui/react";
+import { Textarea } from "@heroui/input";
 import { AddRatingStar } from "./AddRatingStar";
 import { Button } from "@components/common/button/Button";
 import { useCustomToast } from "@utils/hooks/useToast";

+ 2 - 1
src/components/catalog/review/ReviewDetail.tsx

@@ -4,7 +4,8 @@ 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 { Avatar } from "@heroui/avatar";
+import { Tooltip } from "@heroui/tooltip";
 import clsx from "clsx";
 import { FC, useState } from "react";
 import ReviewSection from "./ReviewSection";

+ 1 - 1
src/components/catalog/review/ReviewSection.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { useState } from "react";
-import { Modal, ModalContent } from "@heroui/react";
+import { Modal, ModalContent } from "@heroui/modal";
 import AddProductReview from "./AddProductReview";
 import { ReviewButton } from "@components/common/button/ReviewButton";
 import { NoReview } from "./NoReview";

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

@@ -1,6 +1,69 @@
 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 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;
@@ -43,17 +106,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;
   };

+ 2 - 1
src/components/checkout/stepper/payment/PaymentMethod.tsx

@@ -1,7 +1,8 @@
 "use client";
 
 import { Controller, FieldValues, useForm } from "react-hook-form";
-import { cn, Radio, RadioGroup } from "@heroui/react";
+import { cn } from "@heroui/theme";
+import { Radio, RadioGroup } from "@heroui/radio";
 import { useState } from "react";
 import { useCustomToast } from "@utils/hooks/useToast";
 import { useCheckout } from "@utils/hooks/useCheckout";

+ 3 - 2
src/components/checkout/stepper/payment/index.tsx

@@ -1,11 +1,12 @@
 "use client";
 
 import { CartCheckoutPageSkeleton } from "@/components/common/skeleton/CheckoutSkeleton";
-import { useQuery } from "@apollo/client";
+import { useQuery } from "@apollo/client/react";
 import PaymentMethod from "./PaymentMethod";
 import { FC } from "react";
 import { GET_CHECKOUT_PAYMENT_METHODS } from "@/graphql";
 import { getCartToken } from "@/utils/getCartToken";
+import { CheckoutPaymentMethodsData } from "@/types/checkout/type";
 
 const Payment: FC<{
   selectedPayment?: {
@@ -15,7 +16,7 @@ const Payment: FC<{
   currentStep?: string;
 }> = ({ selectedPayment, currentStep }) => {
   const token = getCartToken();
-  const { data, loading: isLoading } = useQuery(GET_CHECKOUT_PAYMENT_METHODS, {
+  const { data, loading: isLoading } = useQuery<CheckoutPaymentMethodsData>(GET_CHECKOUT_PAYMENT_METHODS, {
     variables: { token: token || "" },
     skip: !token,
     fetchPolicy: "cache-first",

+ 6 - 4
src/components/checkout/stepper/shipping/ShippingMethod.tsx

@@ -1,7 +1,8 @@
 "use client";
 
 import { FieldValues, useForm, Controller } from "react-hook-form";
-import { cn, Radio, RadioGroup } from "@heroui/react";
+import { cn } from "@heroui/theme";
+import { Radio, RadioGroup } from "@heroui/radio";
 import { useState } from "react";
 import { ProceedToCheckout } from "../ProceedToCheckout";
 import { useCustomToast } from "@utils/hooks/useToast";
@@ -58,7 +59,7 @@ export default function ShippingMethod({
       const selectedRate = shippingMethod?.find(m => m.method === data.method);
       if (selectedRate) {
         dispatch(updateCart({
-          shippingMethod: selectedRate?.method || "",
+          // shippingMethod: selectedRate?.method || "",
           selectedShippingRate: selectedRate?.method || "",
           selectedShippingRateTitle: selectedRate?.label || "",
         }));
@@ -197,7 +198,7 @@ export default function ShippingMethod({
 }
 
 const CustomRadio = (props: CustomRadioProps) => {
-  const { children, ...otherProps } = props;
+  const { children, className, ...otherProps } = props;
 
   return (
     <Radio
@@ -206,7 +207,8 @@ const CustomRadio = (props: CustomRadioProps) => {
         base: cn(
           "inline-flex m-0 bg-transparent hover:bg-transparent items-center",
           "flex-row items-baseline max-w-full cursor-pointer rounded-lg gap-4 p-4 border-2 border-transparent",
-          "data-[selected=true]:border-primary"
+          "data-[selected=true]:border-primary",
+          className
         ),
         hiddenInput: "peer absolute h-0 w-0 opacity-0",
       }}

+ 3 - 3
src/components/checkout/stepper/shipping/index.tsx

@@ -1,10 +1,10 @@
 "use client";
 
 import { CartCheckoutPageSkeleton } from "@/components/common/skeleton/CheckoutSkeleton";
-import { useQuery } from "@apollo/client";
+import { useQuery } from "@apollo/client/react";
 import ShippingMethod from "./ShippingMethod";
 import { FC } from "react";
-import { SelectedShippingRateType } from "@/types/checkout/type";
+import { SelectedShippingRateType, GetCheckoutShippingRatesData } from "@/types/checkout/type";
 import { GET_CHECKOUT_SHIPPING_RATES } from "@/graphql";
 import { getCartToken } from "@/utils/getCartToken";
 
@@ -14,7 +14,7 @@ const Shipping: FC<{
 }> = ({ selectedShippingRate, currentStep }) => {
   const token = getCartToken();
 
-  const { data, loading: isLoading } = useQuery(GET_CHECKOUT_SHIPPING_RATES, {
+  const { data, loading: isLoading } = useQuery<GetCheckoutShippingRatesData>(GET_CHECKOUT_SHIPPING_RATES, {
     variables: { token: token || "" },
     skip: !token,
     fetchPolicy: "cache-first",

+ 1 - 1
src/components/checkout/type.ts

@@ -1,4 +1,4 @@
-import { Radio } from "@heroui/react";
+import { Radio } from "@heroui/radio";
 import { FieldErrors, UseFormRegister } from "react-hook-form";
 
 export type CustomRadioProps = {

+ 312 - 0
src/components/common/AddToCartModal/AddToCartModal.tsx

@@ -0,0 +1,312 @@
+"use client";
+
+import { useState, useMemo } from "react";
+import { redirect, RedirectType } from 'next/navigation'
+import { useAppDispatch, useAppSelector } from "@/store/hooks";
+import { useCustomToast } from "@utils/hooks/useToast";
+import { 
+    closeAddToCartDialog, 
+    clearAddToCartProduct 
+} from '@/store/slices/addToCartDialogSlice';
+import { useAddProduct } from "@utils/hooks/useAddToCart";
+import Portal from "@/components/Portal";
+import {
+  Drawer,
+  DrawerContent,
+  DrawerHeader,
+  DrawerBody,
+  DrawerFooter
+} from "@heroui/drawer";
+import { 
+    ProductOption, 
+    ResolvedVariant 
+} from "@/components/catalog/type";
+import ImgSwiperInAddToCartModal from "./ImgSwiperInAddToCartModal";
+import ProductOptionsInAddToCartModal from "./ProductOptionsInAddToCartModal";
+import FooterBtnInAddToCartModal from "./FooterBtnInAddToCartModal";
+import { Price } from "@components/theme/ui/Price";
+import {
+    isValueAvailable,
+    isOptionValueAvailable,
+    getFirstAvailable,
+    getAvailableVariants,
+    isCombinationAvailable,
+    formatFlexibleVariants,
+    findNearestAvailableVariant
+} from "@/utils/variantTools";
+
+export function AddToCartModal() {
+    const dispatch = useAppDispatch();
+    const {isOpen, product} = useAppSelector((state) => state.addToCartDialog);
+
+    const { isCartLoading, onAddToCart } = useAddProduct();
+    const { showToast } = useCustomToast();
+
+    const images = useMemo(() => {
+        return product?.images?.edges.map((item) => {
+            return item.node;
+        }) ?? [];
+    },[product?.images]);
+    const flexibleVariants = useMemo(() => {
+        return formatFlexibleVariants(product?.flexibleVariants);
+    },[product?.flexibleVariants]);
+
+    const productOptions: ProductOption[] = useMemo(() => {
+        return product?.productOptions ? JSON.parse(product.productOptions) : [];
+    },[product?.productOptions]);
+
+    // 获取所有可用变体
+    const availableVariants = useMemo(() => {
+        return getAvailableVariants(flexibleVariants);
+    },[flexibleVariants]);
+
+    const isSaleable = product?.isSaleable;
+
+    const [productQty, setProductQty] = useState(1);
+
+
+    // 记录当前哪个选项组刚刚被点击(用于实现“点击组内全部可点”)
+    const [lastClickedOptionId, setLastClickedOptionId] = useState<number>(productOptions[0]?.id );
+  
+    // 当前选中的选项值映射:option_id -> value_id
+    // useEffect和useMemo的执行时机不同,useMemo早于useEffect(useEffect在浏览器绘制后执行)执行,
+    // useLayoutEffect早于useEffect执行,useLayoutEffect在浏览器绘制之前执行(官方说会阻塞浏览器绘制)
+    const [selected, setSelected] = useState<Record<number, number>>(() => {
+        // if (Object.keys(selected).length === 0) {
+            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 ---- ', res);
+            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 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, availableVariants, isSaleable]);
+    
+    // 获取当前选中变体和价格等信息
+    const currentVariantInfo: {variant:ResolvedVariant | null; totalLinePrice: number; totalNowPrice: number; save: number;} = useMemo(() => {
+        const selectedIds = Object.values(selected);
+        let totalLinePrice = 0;
+        let totalNowPrice = 0;
+        let save = 0;
+        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 (selectedIds.length === 0 || variant === undefined) {
+            // 尚未初始化,返回一个安全占位对象
+            return { variant: null, totalLinePrice: 0, totalNowPrice: 0, save: 0 };
+        }
+        if(variant.priceIndices && variant.priceIndices.length > 0) {
+            totalLinePrice = Number(variant.priceIndices[0].regular_min_price) * productQty;
+            totalNowPrice = Number(variant.priceIndices[0].min_price) * productQty;
+        }
+        if(totalLinePrice !== totalNowPrice) {
+            save = -(totalLinePrice - totalNowPrice); 
+        }
+
+        return {
+            variant, 
+            totalLinePrice: totalLinePrice, 
+            totalNowPrice: totalNowPrice,
+            save: save
+        };
+    }, [selected, flexibleVariants, productQty]);
+
+
+
+    const handleOptionClick = (result:{
+        clickOptionId: number; 
+        clickValueId: number;
+    }) => {
+        // 更新前需要判断当前选中的组合是否可以购买,如果可以购买正常更新;如果不能购买查找可以购买的组合然后更新
+        let {clickOptionId: optionId, clickValueId: valueId} = result;
+        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);
+    };
+
+    async function addProductToCart(action:string = 'addtocart') {
+            
+        let params = { 
+            productId: String(product?._id), 
+            quantity: productQty,
+            variantId: currentVariantInfo.variant?._id,
+        };
+        let res = await onAddToCart(params);
+        console.log('onAddToCart run ----- 1');
+        if(action === 'buynow') {
+            if(res) {
+                const responseData = res.data?.createAddProductInCart?.addProductInCart;
+                if(responseData && responseData.success) {
+                    redirect('/checkout?step=address', RedirectType.push);
+                }
+            }
+        }
+    } 
+
+    const addToCartHandler = async (callback: ()=> void) => {
+        if(!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+
+        if(currentVariantInfo.variant && !isCartLoading) {
+            await addProductToCart();
+            callback();
+        }
+    };
+    const buyNowHandler = async (callback: ()=> void) => {
+        if(!isCurrentSelectionAvailable) {
+            showToast("The selected options are not available!", "warning");
+            return;
+        }
+
+        if(currentVariantInfo.variant && !isCartLoading) {
+            await addProductToCart('buynow');
+            callback();
+        }
+    };
+
+    const closeModal = () => {
+        dispatch(closeAddToCartDialog());
+        setTimeout(() => {
+            dispatch(clearAddToCartProduct());
+        }, 300);
+    };
+    const openChange = (e:boolean) => {
+        if(!e) {
+            closeModal();
+        }
+    }
+
+    return (
+        <Portal>
+            <Drawer
+                backdrop={"blur"}
+                placement="bottom"
+                isDismissable={false}
+                isKeyboardDismissDisabled={true}
+                isOpen={isOpen}
+                hideCloseButton
+                onOpenChange={(e) => openChange(e)}
+            >
+                <DrawerContent className="rounded-none h-17/20 max-h-none">
+                {(onClose) => (
+                    <>
+                        <DrawerHeader className="flex flex-col gap-1">
+                            <div>
+                                Select Options
+                                <button className="absolute top-2.5 right-2.5 w-6 h-6" onClick={onClose}>
+                                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" className="h-6 transition-all ease-in-out hover:scale-110"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"></path></svg>
+                                </button>
+                            </div>
+                            {/**产品图片 */}
+                            {images.length > 0 && (
+                                <div>
+                                <ImgSwiperInAddToCartModal images={images} />
+                                </div>
+                            )}
+                        </DrawerHeader>
+                        <DrawerBody className="drawer-body relative">
+                            <div>
+                                {/**产品选项 */}
+                                <div className="box-border w-full">
+                                    <ProductOptionsInAddToCartModal 
+                                        productOptions={productOptions}
+                                        selected={selected}
+                                        disabledMap={disabledMap}
+                                        onOptionClick={handleOptionClick}
+                                    />
+                                </div>
+                            </div>
+                        </DrawerBody>
+                        <DrawerFooter className="flex-col border-t border-gray-200 gap-y-4 p-4">
+                            <div className="flex justify-between items-center">
+                                <Price 
+                                    className="text-ly-14 text-ly-gray line-through"
+                                    amount={String(currentVariantInfo.totalLinePrice)} 
+                                    currencyCode="USD"
+                                />
+                                <div className="flex items-center">
+                                    <Price 
+                                        className="text-ly-14 bg-ly-gold py-0.5 px-1"
+                                        amount={String(currentVariantInfo.save)} 
+                                        currencyCode="USD"
+                                    />
+                                    <Price 
+                                        className="text-ly-20 font-bold ml-1.5"
+                                        amount={String(currentVariantInfo.totalNowPrice)} 
+                                        currencyCode="USD"
+                                    />
+                                </div>
+                            </div>
+                            <div>
+                                <FooterBtnInAddToCartModal 
+                                    isAvailable={isCurrentSelectionAvailable} 
+                                    isLoading={isCartLoading}
+                                    onAddToCart={()=> addToCartHandler(onClose)}
+                                    onBuyNow={()=> buyNowHandler(onClose)}
+                                />
+                            </div>
+                        </DrawerFooter>
+                    </>
+                )}
+                </DrawerContent>
+            </Drawer>
+        </Portal>
+    );
+}

+ 12 - 0
src/components/common/AddToCartModal/AddToCartModalWrapper.tsx

@@ -0,0 +1,12 @@
+"use client";
+
+import { AddToCartModal } from "@components/common/AddToCartModal/AddToCartModal";
+import { useAppSelector } from "@/store/hooks";
+
+export function AddToCartModalWrapper() {
+  const {product} = useAppSelector((state) => state.addToCartDialog);
+
+  return (
+    <AddToCartModal key={product?.id} />
+  );
+}

+ 40 - 0
src/components/common/AddToCartModal/FooterBtnInAddToCartModal.tsx

@@ -0,0 +1,40 @@
+"use client";
+
+import clsx from "clsx";
+import { LoadingSpinner } from "@components/common/LoadingSpinner";
+export default function FooterBtnInAddToCartModal({
+    isAvailable,
+    isLoading,
+    onBuyNow,
+    onAddToCart
+}: {
+    isAvailable: boolean; 
+    isLoading: boolean;
+    onBuyNow: () => void,
+    onAddToCart: () => void
+}) {
+
+    const btnClass = 'flex-initial flex items-center justify-center w-40 h-ly-46 rounded-4xl text-ly-16 font-bold text-white';
+    return (
+        <div className="flex justify-between">
+            {isAvailable ? 
+                (<>
+                    <button className={clsx(btnClass,'bg-ly-deepgreen')}
+                        onClick={onBuyNow}
+                    >
+                        Buy Now {isLoading && <LoadingSpinner className="ml-2" />}
+                        
+                    </button>
+                    <button className={clsx(btnClass, 'bg-ly-middlegreen')}
+                        onClick={onAddToCart}
+                    >
+                        Add to Cart {isLoading && <LoadingSpinner className="ml-2" />}
+                    </button>
+                </>)
+            :
+                <p className="text-center text-ly-14">The selected options are not available!</p>
+            }
+            
+        </div>
+    );
+}

+ 27 - 0
src/components/common/AddToCartModal/ImgSwiperInAddToCartModal.tsx

@@ -0,0 +1,27 @@
+"use client";
+import { memo } from "react";
+import { Swiper, SwiperSlide } from 'swiper/react';
+import 'swiper/css';
+import Image from "next/image";
+import { ProductMediaType } from "@/components/catalog/type";
+
+const ImgSwiperInAddToCartModal = memo(function ImgSwiperInAddToCartModal({images}: {images: ProductMediaType[];}) { 
+    return (
+        <Swiper
+            spaceBetween={10}
+            slidesPerView={3}
+            onSlideChange={() => console.log('slide change')}
+            onSwiper={(swiper) => console.log(swiper)}
+        >
+            {images.map((img) => {
+                return (
+                <SwiperSlide key={img.id}>
+                    <Image src={img.publicPath} alt={"todo test "} width={175} height={233} />
+                </SwiperSlide>
+                );
+            })}
+        </Swiper>
+    );
+});
+
+export default ImgSwiperInAddToCartModal;

+ 61 - 0
src/components/common/AddToCartModal/OpenAddToCartModalButton.tsx

@@ -0,0 +1,61 @@
+"use client";
+
+import { useAppDispatch } from "@/store/hooks";
+import { openAddToCartDialog } from '@/store/slices/addToCartDialogSlice';
+import { GET_PRODUCT_BY_URL_KEY } from "@/graphql";
+import { useLazyQuery } from '@apollo/client/react';
+import { SingleProductResponse } from "@/components/catalog/type";
+import { LoadingSpinner } from "@components/common/LoadingSpinner";
+import ShoppingCartIcon from "@components/common/icons/ShoppingCartIcon";
+import { useCustomToast } from "@utils/hooks/useToast";
+
+export function OpenAddToCartModalButton({productUrlKey}: {productUrlKey: string}) {
+    const dispatch = useAppDispatch();
+    // const [loading, setLoading] = useState(false);
+    const { showToast } = useCustomToast();
+
+    const [getContent, { loading }] = useLazyQuery<SingleProductResponse>(GET_PRODUCT_BY_URL_KEY,{
+        fetchPolicy: 'network-only'
+    });
+    const openDialog = async () => {
+        if( loading) return; 
+        try {
+            let result = await getContent({
+                variables: { urlKey: productUrlKey }
+            });
+
+            console.log('product ----- ', result);// 返回的数据中有__typename,如何过滤 @todo
+
+            let 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.log('error ----- ', error);
+            let errorMessage = "Network Error";
+            if (error instanceof Error) {
+                errorMessage = error.message;
+            } else if (typeof error === 'object' && error !== null && 'message' in error) {
+                errorMessage = String((error as any).message);
+            }
+            
+            showToast(errorMessage, "danger");
+        }
+       
+    }
+    return (
+
+        <button onClick={openDialog} disabled={loading}>
+            { loading ? <LoadingSpinner colorClassName={"text-ly-green"} /> : <ShoppingCartIcon />}
+        </button>
+        
+    );
+};

+ 63 - 0
src/components/common/AddToCartModal/ProductOptionsInAddToCartModal.tsx

@@ -0,0 +1,63 @@
+"use client";
+
+import clsx from "clsx";
+import { 
+    ProductOption, 
+} from "@/components/catalog/type";
+
+export default function ProductOptionsInAddToCartModal({
+    productOptions,
+    selected,
+    disabledMap,
+    onOptionClick,
+}: {
+    productOptions: ProductOption[];
+    selected: Record<number, number>;
+    disabledMap: Record<number, boolean>;
+    onOptionClick: (result:{
+        clickOptionId: number; 
+        clickValueId: number;
+    }) => void;
+}) {
+
+    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";
+    // 处理选项点击
+    const handleOptionClick = (optionId: number, valueId: number) => {
+        onOptionClick({
+            clickOptionId: optionId, 
+            clickValueId: valueId,
+        });
+    };
+
+    return (
+        <>
+            this is test
+            {productOptions.map((option) => { 
+                return (<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] ? true : 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>)
+            })}
+        </>
+    );
+}

+ 24 - 10
src/components/common/LoadingSpinner.tsx

@@ -1,25 +1,39 @@
 import { FC } from "react";
 
 interface LoadingSpinnerProps {
-  size?: "sm" | "md" | "lg";
+  colorClassName?: string;
+  size?: 16 | 24 | 32 | 48;
   className?: string;
 }
 
 export const LoadingSpinner: FC<LoadingSpinnerProps> = ({ 
-  size = "md", 
-  className = "" 
+  size = 24, 
+  className = "" ,
+  colorClassName = "text-success"
 }) => {
   const sizeClasses = {
-    sm: "h-4 w-4",
-    md: "h-8 w-8", 
-    lg: "h-12 w-12"
+    16: "h-4 w-4",
+    24: "h-6 w-6",
+    32: "h-8 w-8", 
+    48: "h-12 w-12"
   };
 
   return (
-    <div className={`flex items-center justify-center py-8 ${className}`}>
-      <div 
-        className={`animate-spin rounded-full border-b-2 border-neutral-800 dark:border-white ${sizeClasses[size]}`}
-      />
+    <div className={`flex items-center justify-center ${className}`}>
+        <svg viewBox="0 0 32 32" fill="none" strokeWidth="3" 
+          className={`z-0 relative overflow-hidden ${colorClassName} animate-spinner-ease-spin ${sizeClasses[size]}`}
+        >
+            <circle cx="16" cy="16" r="13" role="presentation" 
+                strokeDasharray="81.68140899333463 81.68140899333463" 
+                strokeDashoffset="0" transform="rotate(-90 16 16)" 
+                strokeLinecap="round" 
+                className="h-full stroke-default-300/50"></circle>
+            <circle cx="16" cy="16" r="13" role="presentation" 
+                strokeDasharray="81.68140899333463 81.68140899333463" 
+                strokeDashoffset="61.26105674500097" transform="rotate(-90 16 16)" 
+                strokeLinecap="round" 
+                className="h-full stroke-current transition-all !duration-500"></circle>
+        </svg>
     </div>
   );
 };

+ 1 - 1
src/components/common/button/ReviewButton.tsx

@@ -1,5 +1,5 @@
 import { IS_GUEST } from "@utils/constants";
-import { getCookie } from "@utils/getCartToken";
+import { getCookie } from "@utils/cookie-tools";
 import { useRouter } from "next/navigation";
 
 export const ReviewButton = ({ setShowForm, className }: { setShowForm: (show: boolean) => void, className?: string }) => {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 11 - 37
src/components/common/icons/LogoIcon.tsx


+ 3 - 3
src/components/common/slider/HeroCarouselShimmer.tsx

@@ -1,14 +1,14 @@
 "use client";
 
 import { Shimmer } from "@/components/common/Shimmer";
-
-export function HeroCarouselShimmer() {
+// 占位组件
+export function HeroCarouselShimmer({ratio = "3/4"} : {ratio?: string}) {
   return (
     <div className="group relative overflow-hidden">
       <div
         className="group relative h-full max-h-[738px] w-full overflow-hidden rounded-2xl"
         style={{
-          aspectRatio: "380/316",
+          aspectRatio: ratio,
         }}
       >
         <div className="relative h-full w-full">

+ 13 - 6
src/components/customer/LoginForm.tsx

@@ -11,10 +11,10 @@ import { EMAIL_REGEX, SIGNIN_IMG } from "@/utils/constants";
 import InputText from "@components/common/form/Input";
 import { useCustomToast } from "@/utils/hooks/useToast";
 import { useMergeCart } from "@utils/hooks/useMergeCart";
-import { getCookie } from "@utils/getCartToken";
-import { setCookie } from "@utils/helper";
+
+import { getCookie, setCookie } from "@/utils/cookie-tools";
 import { setLocalStorage } from "@/store/local-storage";
-import { useAppDispatch } from "@/store/hooks";
+import { useAppDispatch, useAppSelector } from "@/store/hooks";
 import { setUser } from "@/store/slices/user-slice";
 import { useCartDetail } from "@utils/hooks/useCartDetail";
 import { GUEST_CART_ID, GUEST_CART_TOKEN, IS_GUEST } from "@/utils/constants";
@@ -30,7 +30,7 @@ export default function LoginForm() {
   const { showToast } = useCustomToast();
   const { getCartDetail } = useCartDetail()
   const { mergeCart } = useMergeCart();
-
+  const cart = useAppSelector((state) => state.cartDetail.cart);
   const {
     register,
     handleSubmit,
@@ -46,11 +46,16 @@ export default function LoginForm() {
       const guestCartId = getCookie(GUEST_CART_ID);
       const guestCartToken = getCookie(GUEST_CART_TOKEN);
 
+      /**
+       * @todo 使用 signInAuth 重写
+       * result: {error: null,ok: true,status: 200,url: "http://localhost:3001/"}
+       */
       const result = await signIn("credentials", {
         redirect: false,
         ...data,
-        callbackUrl: "/",
+        callbackUrl: "/", // The callbackUrl specifies to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from.
       });
+      console.log('result --- ',result);
 
       if (!result?.ok) {
         showToast(result?.error || "Invalid login credentials.", "warning");
@@ -73,7 +78,9 @@ export default function LoginForm() {
       // Only merge cart if user had a guest cart before login
       if (userToken && guestCartId && guestCartToken) {
         try {
-          await mergeCart({ variables: { token: userToken, cartId: parseInt(guestCartId, 10) } });
+          if(cart) {
+            await mergeCart({ variables: { token: userToken, cartId: parseInt(guestCartId, 10) } });
+          }
           setCookie(GUEST_CART_TOKEN, userToken);
           setCookie(IS_GUEST, "false");
           await getCartDetail();

+ 9 - 0
src/components/customer/RegistrationForm.tsx

@@ -32,7 +32,16 @@ export default function RegistrationForm() {
 
   const { showToast } = useCustomToast();
 
+  /**
+   * 
+   * 浏览器端 RegistrationForm.onSubmit
+      → 调用 createUser(data)  ← 这是一个 Server Action
+        → 发送 POST 请求到当前页面路由(或者由框架生成的端点)
+          → 服务端执行 createUser 函数(访问 cookies、session、数据库等)
+            → 返回结果给浏览器 
+   */
   const onSubmit: SubmitHandler<RegisterInputs> = async (data) => {
+    console.log('register run where?--------');
     if (data.password !== data.passwordConfirmation) {
       showToast("The Passwords do not match.", "warning");
       return;

+ 90 - 0
src/components/customer/SettingModal.tsx

@@ -0,0 +1,90 @@
+"use client";
+
+export default function SettingModal({
+  visible,
+  onClose,
+}: {
+  visible: boolean;
+  onClose: () => void;
+}) {
+  // 如果不显示,直接 return null
+  if (!visible) return null;
+
+  return (
+    // 全屏遮罩
+    <div className="fixed inset-0 z-50 bg-black/50">
+      {/* 弹框主体 - 从底部滑上来 */}
+      <div className="absolute bottom-0 left-0 right-0 w-full h-full bg-white rounded-t-2xl overflow-auto">
+        <div className="flex flex-col">
+          {/* 顶部导航 */}
+          <header className="bg-black text-white py-4 px-4 flex items-center">
+            <button onClick={onClose} className="mr-4">
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                className="h-6 w-6"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M15 19l-7-7 7-7"
+                />
+              </svg>
+            </button>
+            <h1 className="text-lg font-medium">Setting</h1>
+          </header>
+
+          {/* 头像区域 */}
+          <div className="bg-[#FFF5F0] py-8 flex flex-col items-center">
+            <div className="w-20 h-20 bg-white rounded-full flex items-center justify-center border-2 border-black">
+              <span className="text-black font-bold text-lg">LOGO</span>
+            </div>
+          </div>
+
+          {/* 菜单列表 */}
+          <div className="px-4 py-2">
+            {/* Information */}
+            <div className="py-4 border-b border-gray-100 flex justify-between items-center">
+              <span className="text-black font-medium">Information</span>
+              <svg
+                xmlns="http://www.w3.org/2000/svg"
+                className="h-5 w-5 text-gray-400"
+                fill="none"
+                viewBox="0 0 24 24"
+                stroke="currentColor"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M9 5l7 7-7 7"
+                />
+              </svg>
+            </div>
+
+            {/* Subscribe 开关 */}
+            <div className="py-4 border-b border-gray-100 flex justify-between items-center">
+              <span className="text-black font-medium">Subscribe</span>
+              <div className="relative w-12 h-6 rounded-full bg-black">
+                <div className="absolute right-0.5 top-0.5 w-5 h-5 bg-white rounded-full"></div>
+              </div>
+            </div>
+
+            {/* Log Out */}
+            <div className="py-4 border-b border-gray-100">
+              <button className="text-black font-medium w-full text-left">
+                Log Out
+              </button>
+            </div>
+          </div>
+
+          {/* 版本号 */}
+          <div className="py-6 text-center text-gray-400 text-sm">v1.1.0</div>
+        </div>
+      </div>
+    </div>
+  );
+}

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

@@ -0,0 +1,83 @@
+"use client";
+import { Swiper, SwiperSlide } from 'swiper/react';
+import 'swiper/css';
+// 模拟商品数据(和图片内容匹配)
+const productList = [
+  {
+    id: 1,
+    imgUrl: "https://cdn.alipearlhair.com/media/catalog/product/cache/6/thumbnail/750x1000/9df78eab33525d08d6e5fb8d27136e95/b/o/body_wave_lace_front_wig_2.jpg", // 替换成你的真实图片路径(public下)
+    title: "Widow's Peak M Hairline 13x6 HD Lace Front Wig N...",
+    price: "$132.91",
+    originalPrice: "$204.16",
+    tag: "Extra $50 OFF | Code: MW50 | This Week",
+  },
+  {
+    id: 2,
+    imgUrl: "https://cdn.alipearlhair.com/media/catalog/product/cache/6/thumbnail/750x1000/9df78eab33525d08d6e5fb8d27136e95/b/o/body_wave_lace_front_wig_2.jpg", // 替换成你的真实图片路径
+    title: "Alipearl Deep Wave Wig 100% Human Hair Swiss...",
+    price: "$149.66",
+    originalPrice: "$199.54",
+    tag: "", // 第二个商品无标签
+  },
+  // 可添加更多商品
+];
+const AccountSwiper = () => {
+  return (
+    <div className="w-full max-w-[700px] mx-auto px-4">
+      {/* Swiper 核心容器 */}
+      <Swiper
+     
+        slidesPerView={2} // 一行显示2个商品(和图片一致)
+        spaceBetween={8} // 商品之间的间距
+        breakpoints={{
+          // 响应式适配(小屏显示1个)
+          390: { slidesPerView: 2 },
+          640: { slidesPerView: 2 },
+        }}
+        className="product-swiper"
+      >
+        {/* 循环渲染商品卡片 */}
+        {productList.map((product) => (
+          <SwiperSlide key={product.id} className="rounded-lg overflow-hidden">
+            <div className="flex flex-col h-full">
+              {/* 商品图片(带标签) */}
+              <div className="relative">
+                <img
+                  src={product.imgUrl}
+                  alt={product.title}
+                  className="w-full h-auto object-cover rounded-t-lg"
+                />
+                {/* 促销标签(有内容才显示) */}
+                {product.tag && (
+                  <div className="absolute bottom-2 left-2 bg-black/80 text-white text-xs px-2 py-1 rounded">
+                    {product.tag}
+                  </div>
+                )}
+              </div>
+
+              {/* 商品标题 */}
+              <p className="mt-3 text-sm text-gray-800 font-medium line-clamp-2">
+                {product.title}
+              </p>
+
+              {/* 价格区域 */}
+              <div className="flex items-center gap-2 mt-2">
+                <span className="text-red-600 font-bold">{product.price}</span>
+                <span className="text-xs text-gray-500 line-through">
+                  {product.originalPrice}
+                </span>
+                {/* 购物车图标 */}
+                <span className="ml-auto text-gray-600">🛒</span>
+              </div>
+            </div>
+          </SwiperSlide>
+        ))}
+      </Swiper>
+
+      {/* 分页器容器(底部小圆点) */}
+      {/* <div className="swiper-pagination flex justify-center mt-4"></div> */}
+    </div>
+  );
+};
+
+export default AccountSwiper;

+ 28 - 58
src/components/customer/credentials/CredentialModal.tsx

@@ -1,7 +1,6 @@
 "use client";
 
-import { Popover, PopoverTrigger, PopoverContent } from "@heroui/popover";
-import { useDisclosure } from "@heroui/react";
+import { useDisclosure } from "@heroui/use-disclosure";
 import { AnimatePresence, motion } from "framer-motion";
 import clsx from "clsx";
 import { signOut } from "next-auth/react";
@@ -10,7 +9,6 @@ import { Avatar } from "@heroui/avatar";
 import { useForm } from "react-hook-form";
 import { usePathname, useRouter } from "next/navigation";
 import { useCustomToast } from '@/utils/hooks/useToast';
-import { useMediaQuery } from "@utils/hooks/useMediaQueryHook";
 import { useBodyScrollLock } from "@utils/hooks/useBodyScrollLock";
 import OpenAuth from "../OpenAuth";
 import { isObject } from '@/utils/type-guards';
@@ -52,20 +50,11 @@ export default function CredentialModal({
   const router = useRouter();
   const dispatch = useAppDispatch();
   const { showToast } = useCustomToast();
-  const isDesktop = useMediaQuery("(min-width: 1024px)");
+
   const { resetGuestToken } = useGuestCartToken();
 
-  useBodyScrollLock(finalIsOpen && !isDesktop);
+  useBodyScrollLock(finalIsOpen );
 
-  const finalOnOpenChange = (open: boolean) => {
-    if (isControlled) {
-      if (open) onOpen?.();
-      else onClose?.();
-    } else {
-      if (open) internalOnOpen();
-      else internalOnClose();
-    }
-  };
 
   const {
     handleSubmit,
@@ -105,33 +94,33 @@ export default function CredentialModal({
 
   const innerContent = (_onClose?: () => void) => (
     <div className={clsx("flex w-full flex-col rounded-md py-4", {
-      "gap-y-6": !!session?.user || (!session?.user && isDesktop),
-      "gap-y-10": !session?.user && !isDesktop,
+      "gap-y-6": !!session?.user,
+      "gap-y-10": !session?.user,
     })}>
       {isObject(session?.user) ? (
         <>
           <header>
-            <div className={clsx("flex flex-col gap-3", !isDesktop && "items-center justify-center")}>
-              <div className={clsx("flex gap-3", !isDesktop ? "flex-col items-center" : "items-center")}>
+            <div className={clsx("flex flex-col gap-3", "items-center justify-center")}>
+              <div className={clsx("flex gap-3",  "flex-col items-center")}>
                 <Avatar
                   isBordered
                   showFallback
                   color="default"
-                  icon={<OpenAuth className={clsx(isDesktop ? "h-8" : "h-12 w-12")} />}
-                  size={isDesktop ? "md" : "lg"}
-                  className={clsx(!isDesktop && "h-24 w-24 text-large")}
+                  icon={<OpenAuth className={clsx("h-12 w-12")} />}
+                  size={"lg"}
+                  className={clsx( "h-24 w-24 text-large")}
                 />
-                <div className={clsx("flex flex-col justify-center", !isDesktop ? "items-center gap-1" : "items-start")}>
-                  <h4 className={clsx("leading-none dark:text-white", isDesktop ? "font-semibold text-default-500 text-small" : "text-xl font-bold text-black")}>
+                <div className={clsx("flex flex-col justify-center", "items-center gap-1")}>
+                  <h4 className={clsx("leading-none dark:text-white", "text-xl font-bold text-black")}>
                     {session?.user?.name}
                   </h4>
-                  <h5 className={clsx("tracking-tight dark:text-white", isDesktop ? "text-default-500 text-small" : "text-sm text-gray-500")}>
+                  <h5 className={clsx("tracking-tight dark:text-white",  "text-sm text-gray-500")}>
                     {session?.user?.email}
                   </h5>
                 </div>
               </div>
 
-              <p className={clsx("text-default-500 dark:text-white", isDesktop ? "text-small pl-px" : "text-center mt-2")}>
+              <p className={clsx("text-default-500 dark:text-white", "text-center mt-2")}>
                 Manage Cart, Orders
                 <span aria-label="confetti" className="px-2" role="img">
                   🎉
@@ -141,12 +130,12 @@ export default function CredentialModal({
           </header>
 
           <footer>
-            <form onSubmit={handleSubmit(onSubmit)} className={clsx(!isDesktop && "flex justify-center")}>
+            <form onSubmit={handleSubmit(onSubmit)} className={clsx("flex justify-center")}>
               <button
                 className={clsx(
                   "rounded-full bg-gray-800 px-5 py-2.5 text-sm font-medium text-white hover:bg-gray-900 focus:outline-none focus:ring-4 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-700",
                   isSubmitting ? " cursor-not-allowed" : " cursor-pointer",
-                  isDesktop ? "w-full" : "w-40 min-w-[150px] mt-2"
+                  "w-40 min-w-[150px] mt-2"
                 )}
                 type="submit"
               >
@@ -166,14 +155,12 @@ export default function CredentialModal({
         </>
       ) : (
         <>
-          <header className={clsx({ "text-center": !isDesktop })}>
+          <header className="text-center">
             <div className="flex flex-col gap-y-2">
-              <h4 className={clsx("font-bold leading-none text-black dark:text-white",
-                isDesktop ? "text-xl" : "text-3xl")}>
+              <h4 className="font-bold leading-none text-black dark:text-white text-3xl">
                 Welcome Guest
               </h4>
-              <p className={clsx("text-default-500 dark:text-neutral-400",
-                isDesktop ? "text-sm" : "text-lg")}>
+              <p className="text-default-500 dark:text-neutral-400 text-lg">
                 Manage Cart, Orders
                 <span aria-label="confetti" className="px-2" role="img">
                   🎉
@@ -218,31 +205,6 @@ export default function CredentialModal({
     </div>
   );
 
-  if (isDesktop) {
-    return (
-      <Popover
-        backdrop="opaque"
-        isOpen={finalIsOpen}
-        onOpenChange={finalOnOpenChange}
-        defaultOpen={false}
-        color="default"
-        placement="bottom-end"
-      >
-        <PopoverTrigger>
-          <button
-            type="button"
-            aria-label="Open account"
-            className={clsx(className, "cursor-pointer bg-transparent")}
-          >
-            {children ? children : <OpenAuth />}
-          </button>
-        </PopoverTrigger>
-        <PopoverContent className="min-w-[300px] px-4">
-          {innerContent(finalOnClose)}
-        </PopoverContent>
-      </Popover>
-    );
-  }
 
   return (
     <>
@@ -263,7 +225,7 @@ export default function CredentialModal({
               animate={{ opacity: 1 }}
               exit={{ opacity: 0 }}
               onClick={finalOnClose}
-              className="fixed inset-0 z-40 bg-transparent lg:hidden"
+              className="fixed inset-0 z-40 bg-transparent"
               style={{ top: "68px", bottom: "64px" }}
             />
 
@@ -284,6 +246,14 @@ export default function CredentialModal({
               <div className="flex flex-col gap-1 border-b border-neutral-100 p-4 dark:border-neutral-800">
                 <div className="flex items-center justify-between">
                   <p className="text-xl font-semibold dark:text-white">Account</p>
+                  <button
+                    aria-label="Close account"
+                    className="rounded-full p-1 hover:bg-neutral-100 "
+                    onClick={finalOnClose}
+                    type="button"
+                  >
+                    <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon" className="h-6 transition-all ease-in-out hover:scale-110"><path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12"></path></svg>
+                  </button>
                 </div>
               </div>
 

+ 3 - 3
src/components/home/CategoryCarousel.tsx

@@ -36,7 +36,7 @@ const MobileCategoryItem: FC<MobileCategoryItemProps> = ({
           "relative block h-full w-full aspect-[380/280]",
           size === "half" && "xxs:aspect-[182/280]"
         )}
-        href={`/search/${category.translation.slug}`}
+        href={`/category/${category.translation.slug}`}
       >
         <GridTileImage
           fill
@@ -72,7 +72,7 @@ const CategoryCarousel: FC<CategoryCarouselProps> = async ({
   options: _options,
 }) => {
   try {
-    const data = await cachedGraphQLRequest<CategoriesResponse>(
+    const {data} = await cachedGraphQLRequest<CategoriesResponse>(
       "home",
       GET_HOME_CATEGORIES,
       {}
@@ -131,7 +131,7 @@ const CategoryCarousel: FC<CategoryCarouselProps> = async ({
               >
                 <Link
                   className="relative h-full w-full"
-                  href={`/search/${category.translation.slug}`}
+                  href={`/category/${category.translation.slug}`}
                   aria-label={`Shop ${category.translation.name} category`}
                 >
                   <GridTileImage

+ 1 - 1
src/components/home/ImageCarousel.tsx

@@ -147,7 +147,7 @@ const ImageCarousel: FC<ImageCarouselProps> = ({ options }) => {
                         >
                             {img.link ? (
                                 <Link
-                                    href={`/search/${img.link}`}
+                                    href={`${img.link}`}
                                     className="block h-full w-full"
                                     aria-label={`View ${altText}`}
                                 >

+ 1 - 1
src/components/home/ProductCarousel.tsx

@@ -43,7 +43,7 @@ const ProductCarousel: FC<ProductCarouselProps> = async ({
       reverse = true;
     }
 
-    const data = await cachedGraphQLRequest<any>(
+    const {data} = await cachedGraphQLRequest<any>(
       "home",
       GET_PRODUCTS,
       {

+ 0 - 105
src/components/layout/navbar/BottomNavbar.tsx

@@ -1,105 +0,0 @@
-"use client";
-
-import Cart from "@components/cart";
-import { CategoryIcon } from "@components/common/icons/CategoryIcon";
-import { HomeIcon } from "@components/common/icons/HomeIcon";
-import { IconSkeleton } from "@components/common/skeleton/IconSkeleton";
-import UserAccount from "@components/customer/credentials";
-import Link from "next/link";
-import { Suspense, memo } from "react";
-import clsx from "clsx";
-import OpenCart from "@components/cart/OpenCart";
-import { useAppSelector } from "@/store/hooks";
-import OpenAuth from "@components/customer/OpenAuth";
-
-type Tab = "home" | "category" | "cart" | "account" | null;
-
-const BottomNavbar = memo(function BottomNavbar({
-  onMenuOpen,
-  activeTab,
-  setActiveTab,
-}: {
-  onMenuOpen: () => void;
-  activeTab: Tab;
-  setActiveTab: (tab: Tab) => void;
-}) {
-  const cartDetail = useAppSelector((state) => state.cartDetail);
-  const itemBase =
-    "flex flex-col items-center gap-1 text-xs font-semibold py-2 rounded-lg transition-colors cursor-pointer";
-
-  const getIconWrapperClass = (tab: Tab) =>
-    clsx(
-      "flex items-center justify-center rounded-full transition-all duration-300 px-6 py-1",
-      activeTab === tab
-        ? "bg-selected-color dark:bg-selected-bg-bottom-dark dark:text-selected-bottom-dark"
-        : "bg-transparent text-neutral-900 dark:text-neutral-400"
-    );
-
-  return (
-    <div className="fixed inset-x-0 bottom-0 z-30 lg:hidden">
-      <nav className="px-3 h-16 border-t border-neutral-200 bg-white dark:border-neutral-800 dark:bg-black">
-        <div className="flex h-full items-center justify-between">
-
-          {/* Home */}
-          <Link
-            href="/"
-            aria-label="Go to Home Page"
-            onClick={() => setActiveTab("home")}
-            className={itemBase}
-          >
-            <div className={getIconWrapperClass("home")}>
-              <HomeIcon />
-            </div>
-            <span>Home</span>
-          </Link>
-
-          {/* Categories */}
-          <button
-            onClick={() => {
-              setActiveTab("category");
-              onMenuOpen();
-            }}
-            type="button"
-            className={itemBase}
-          >
-            <div className={getIconWrapperClass("category")}>
-              <CategoryIcon />
-            </div>
-            <span>Categories</span>
-          </button>
-
-          {/* Cart */}
-          <Cart
-            className={itemBase}
-            onOpen={() => setActiveTab("cart")}
-            onClose={() => setActiveTab(null)}
-            isOpen={activeTab === "cart"}
-          >
-            <div className={getIconWrapperClass("cart")}>
-              <OpenCart quantity={cartDetail?.cart?.itemsQty} />
-            </div>
-            <span>Cart</span>
-          </Cart>
-
-          {/* Account */}
-          <Suspense fallback={<IconSkeleton />}>
-            <UserAccount
-              className={itemBase}
-              onOpen={() => setActiveTab("account")}
-              onClose={() => setActiveTab(null)}
-              isOpen={activeTab === "account"}
-            >
-              <div className={getIconWrapperClass("account")}>
-                <OpenAuth />
-              </div>
-              <span>Account</span>
-            </UserAccount>
-          </Suspense>
-
-        </div>
-      </nav>
-    </div>
-  );
-});
-
-export default BottomNavbar;

+ 5 - 8
src/components/layout/navbar/CartAndUserActions.tsx

@@ -1,25 +1,22 @@
 import { Suspense } from "react";
 import Cart from "@/components/cart";
 import UserAccount from "@components/customer/credentials";
-import ThemeSwitcherWrapper from "@components/theme/theme-switch";
 import { IconSkeleton } from "@/components/common/skeleton/IconSkeleton";
 import { SessionManager } from "@/providers";
 
 export function CartAndUserActions() {
   return (
     <div className="flex max-w-fit gap-2 md:gap-4">
-      <div className="flex">
-        <ThemeSwitcherWrapper />
-      </div>
-      <div className="hidden lg:block">
+
+     
         <Cart />
-      </div>
+      
       <Suspense fallback={<IconSkeleton />}>
-        <div className="hidden lg:block">
+        
           <SessionManager>
             <UserAccount />
           </SessionManager>
-        </div>
+       
       </Suspense>
     </div>
   );

+ 0 - 55
src/components/layout/navbar/CategoriesMenu.tsx

@@ -1,55 +0,0 @@
-import Link from "next/link";
-import { GET_TREE_CATEGORIES } from "@/graphql";
-import MobileMenu from "./MobileMenu";
-import { cachedGraphQLRequest } from "@utils/hooks/useCache";
-import { TreeCategoriesResponse } from "@/types/theme/category-tree";
-
-export async function CategoriesMenu() {
-   const data = await cachedGraphQLRequest<TreeCategoriesResponse>(
-    "category",
-    GET_TREE_CATEGORIES,
-    { parentId: 1 }
-  );
-
-
-  const categories = data?.treeCategories || [];
-
-  const filteredCategories = categories
-    .filter((cat: any) => cat.id !== "1")
-    .map((cat: any) => {
-      const translation = cat.translation;
-      return {
-        id: cat.id,
-        name: translation?.name || "",
-        slug: translation?.slug || "",
-      };
-    })
-    .filter((item: any) => item.name && item.slug);
-
-  const menuData = [
-    { id: "all", name: "All", slug: "" },
-    ...filteredCategories.slice(0, 3),
-  ];
-
-  return (
-    <>
-      <MobileMenu menu={menuData} />
-      <ul className="hidden gap-4 text-sm md:items-center lg:flex xl:gap-6">
-        {menuData.map(
-          (item: { id: string; name: string; slug: string }) => (
-            <li key={item?.id + item?.name}>
-              <Link
-                className="text-nowrap relative text-neutral-500 before:absolute before:bottom-0 before:left-0 before:h-px before:w-0 before:bg-current before:transition-all before:duration-300 before:content-[''] hover:text-black hover:before:w-full dark:text-neutral-400 dark:hover:text-neutral-300"
-                href={item.slug ? `/search/${item.slug}` : "/search"}
-                prefetch={true}
-                aria-label={`Browse ${item.name} products`}
-              >
-                {item.name}
-              </Link>
-            </li>
-          )
-        )}
-      </ul>
-    </>
-  );
-}

+ 11 - 19
src/components/layout/navbar/MobileMenu.tsx

@@ -2,32 +2,24 @@
 
 import { AnimatePresence, motion } from "framer-motion";
 import Link from "next/link";
-import BottomNavbar from "./BottomNavbar";
+
 import { MobileSearchBar } from "./MobileSearch";
 import { useState } from "react";
 import { useBodyScrollLock } from "@utils/hooks/useBodyScrollLock";
 
-export default function MobileMenu({ menu }: { menu: any }) {
-  const [activeTab, setActiveTab] = useState<
-    "home" | "category" | "cart" | "account" | null
-  >("home");
+interface MobileMenuProps {
+  menu: any[];
+  isOpen: boolean;
+  onClose: () => void; // 接收关闭函数
+}
+export default function MobileMenu({ menu, isOpen, onClose }: MobileMenuProps) {
 
-  const isOpen = activeTab === "category";
 
   useBodyScrollLock(isOpen);
 
-  const handleClose = () => {
-    setActiveTab(null);
-  };
 
   return (
     <>
-      <BottomNavbar
-        onMenuOpen={() => setActiveTab("category")}
-        setActiveTab={setActiveTab}
-        activeTab={activeTab}
-      />
-
       <AnimatePresence>
         {isOpen && (
           <>
@@ -35,7 +27,7 @@ export default function MobileMenu({ menu }: { menu: any }) {
               initial={{ opacity: 0 }}
               animate={{ opacity: 1 }}
               exit={{ opacity: 0 }}
-              onClick={handleClose}
+              onClick={onClose}
               className="fixed inset-0 z-40 bg-transparent lg:hidden"
               style={{ top: "68px", bottom: "64px" }}
             />
@@ -55,7 +47,7 @@ export default function MobileMenu({ menu }: { menu: any }) {
               }}
             >
               <div className="h-full overflow-y-auto px-4 py-4 drawer-scrollbar-hidden">
-                <MobileSearchBar onClose={handleClose} />
+                <MobileSearchBar onClose={onClose} />
 
                 <h1 className="mt-4 px-2 text-2xl font-semibold text-black dark:text-white">
                   Category
@@ -68,9 +60,9 @@ export default function MobileMenu({ menu }: { menu: any }) {
                       className="p-2 text-xl text-black dark:text-white"
                     >
                       <Link
-                        href={item.slug ? `/search/${item.slug}` : "/search"}
+                        href={item.slug ? `/category/${item.slug}` : "/search"}
                         aria-label={`${item?.name}`}
-                        onClick={handleClose}
+                        onClick={onClose}
                       >
                         {item.name}
                       </Link>

+ 27 - 0
src/components/layout/navbar/MobileMenuTrigger.tsx

@@ -0,0 +1,27 @@
+'use client';
+
+import { useState } from "react";
+import MobileMenu from "./MobileMenu";
+
+export default function MobileMenuTrigger({ menuData }: { menuData: any[] }) {
+  const [isOpen, setIsOpen] = useState(false);
+  const toggleMenu = () => setIsOpen((prev) => !prev);
+  const closeMenu = () => setIsOpen(false);
+
+  return (
+    <>
+      {/* 汉堡按钮 */}
+      <button className="flex-initial w-6 h-6" onClick={toggleMenu}>
+        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
+          <rect x="0" y="0" width="24" height="24" fill="#FFFFFF" fillOpacity="0" />
+          <path stroke="currentColor" strokeWidth="1.5" d="M4 5L20 5" />
+          <path stroke="currentColor" strokeWidth="1.5" d="M4 12L20 12" />
+          <path stroke="currentColor" strokeWidth="1.5" d="M4 19L20 19" />
+        </svg>
+      </button>
+
+      {/* 抽屉菜单 */}
+      <MobileMenu menu={menuData} isOpen={isOpen} onClose={closeMenu} />
+    </>
+  );
+}

+ 41 - 8
src/components/layout/navbar/index.tsx

@@ -3,13 +3,45 @@ import { Suspense } from "react";
 import Search from "./Search";
 import { SearchSkeleton } from "@/components/common/skeleton/SearchSkeleton";
 import LogoIcon from "@components/common/icons/LogoIcon";
-import { CategoriesMenu } from "./CategoriesMenu";
+import MobileMenuTrigger from "./MobileMenuTrigger";
 import { CartAndUserActions } from "./CartAndUserActions";
 import { NavigationSkeleton } from "./NavigationSkeleton";
 import { ActionsSkeleton } from "./ActionsSkeleton";
 import { NavbarErrorBoundary } from "@/components/error/ErrorBoundary";
 
-export default function Navbar() {
+import { GET_TREE_CATEGORIES } from "@/graphql";
+import { cachedGraphQLRequest } from "@utils/hooks/useCache";
+import { TreeCategoriesResponse } from "@/types/theme/category-tree";
+
+export default async function Navbar() {
+
+  const {data} = await cachedGraphQLRequest<TreeCategoriesResponse>(
+      "category",
+      GET_TREE_CATEGORIES,
+      { parentId: 1 }
+    );
+  
+  
+    const categories = data?.treeCategories || [];
+  
+    const filteredCategories = categories
+      .filter((cat: any) => cat.id !== "1")
+      .map((cat: any) => {
+        const translation = cat.translation;
+        return {
+          id: cat.id,
+          name: translation?.name || "",
+          slug: translation?.slug || "",
+        };
+      })
+      .filter((item: any) => item.name && item.slug);
+  
+    const menuData = [
+      { id: "all", name: "All", slug: "" },
+      ...filteredCategories.slice(0, 3),
+    ];
+  
+
   return (
     <NavbarErrorBoundary>
       <header className="sticky top-0 z-10">
@@ -17,21 +49,22 @@ export default function Navbar() {
           <div className="flex w-full items-center justify-between gap-0 sm:gap-4">
             {/* 1. THE STATIC SHELL (Visible Instantly) */}
             <div className="flex max-w-fit gap-2 xl:gap-6">
+
+              {/* 2. STATIC HOLE: Categories (Suspended) */}
+              <Suspense fallback={<NavigationSkeleton />}>
+                <MobileMenuTrigger menuData={menuData} />
+              </Suspense>
               <Link
-                className="flex h-9 w-full scale-95 items-center md:h-9 md:w-auto lg:h-10"
+                className="flex-initial h-9 w-36.5"
                 href="/"
                 aria-label="Go to homepage"
               >
                 <LogoIcon />
               </Link>
               
-              {/* 2. STATIC HOLE: Categories (Suspended) */}
-              <Suspense fallback={<NavigationSkeleton />}>
-                <CategoriesMenu />
-              </Suspense>
             </div>
 
-            <div className="hidden flex-1 justify-center md:flex">
+            <div className="flex-1 justify-center flex">
               <Suspense fallback={<SearchSkeleton />}>
                 <Search search={false} />
               </Suspense>

+ 0 - 57
src/components/theme/category-carousel/Category.tsx

@@ -1,57 +0,0 @@
-import { NOT_IMAGE } from "@/utils/constants";
-import Link from "next/link";
-import { FC } from "react";
-import { GridTileImage } from "../ui/grid/Tile";
-import { BagistoCollectionMenus } from "@/types/types";
-
-const Category: FC<{
-  name: string;
-  categories: BagistoCollectionMenus[];
-}> = ({ name, categories }) => {
-  return (
-    <section>
-      <div className="md:max-w-4.5xl mx-auto mb-10 w-auto px-0 text-center md:px-36">
-        <h2 className="mb-2 text-[28px] font-semibold text-black dark:text-white xss:mb-4 xss:text-4xl">
-          {name}
-        </h2>
-        <p className="font-normal text-black/60 dark:text-neutral-300 text-lg">
-          Discover the latest trends! Fresh products just added—shop new styles,
-          tech, and essentials before they&apos;re gone.
-        </p>
-      </div>
-      <div className="w-full overflow-x-auto overflow-y-hidden">
-        <ul className="m-0 grid grid-cols-1 gap-7 p-0 xss:grid-cols-2 sm:grid-cols-3">
-          {categories.slice(0, 3).map((product) => (
-            <li
-              key={`${product?.id}`}
-              className="relative aspect-[498/665] h-full w-full max-w-[498px] flex-none overflow-hidden rounded-[18px]"
-            >
-              <Link
-                aria-label={`${product?.name}`}
-                className="relative h-full w-full"
-                href={`/search/${product.slug}`}
-              >
-                <GridTileImage
-                  fill
-                  alt={`${product?.name} ${product.id} product image`}
-                  className={
-                    "relative rounded-[18px] overflow-hidden object-cover transition duration-300 ease-in-out group-hover:scale-105"
-                  }
-                  label={{
-                    title: product?.name || "",
-                    page: "category",
-                    amount: "0",
-                    currencyCode: "USD",
-                  }}
-                  src={product?.logoUrl || NOT_IMAGE}
-                />
-              </Link>
-            </li>
-          ))}
-        </ul>
-      </div>
-    </section>
-  );
-};
-
-export default Category;

+ 4 - 3
src/components/theme/filters/MobileFilter.tsx

@@ -5,9 +5,10 @@ import {
   DrawerContent,
   DrawerHeader,
   DrawerBody,
-  Button,
-  useDisclosure,
-} from "@heroui/react";
+} from "@heroui/drawer";
+
+import { Button } from "@heroui/button";
+import { useDisclosure } from "@heroui/use-disclosure";
 import {
   AdjustmentsHorizontalIcon,
 } from "@heroicons/react/24/outline";

+ 4 - 4
src/components/theme/filters/SortOrder.tsx

@@ -9,10 +9,10 @@ import {
   Drawer,
   DrawerContent,
   DrawerHeader,
-  DrawerBody,
-  Button,
-  useDisclosure,
-} from "@heroui/react";
+  DrawerBody
+} from "@heroui/drawer";
+import { Button } from "@heroui/button";
+import { useDisclosure } from "@heroui/use-disclosure";
 import { SortIcon } from "@components/common/icons/SortIcon";
 
 

+ 0 - 78
src/components/theme/theme-switch/ThemeSwitch.tsx

@@ -1,78 +0,0 @@
-"use client";
-
-import { FC } from "react";
-import { VisuallyHidden } from "@react-aria/visually-hidden";
-import { SwitchProps, useSwitch } from "@heroui/switch";
-import { useTheme } from "next-themes";
-import clsx from "clsx";
-import { MoonFilledIcon, SunFilledIcon } from "@/components/common/icons/product-icons";
-
-import { IconSkeleton } from "@/components/common/skeleton/IconSkeleton";
-
-export interface ThemeSwitchProps {
-  className?: string;
-  classNames?: SwitchProps["classNames"];
-}
-
-const ThemeSwitch: FC<ThemeSwitchProps> = ({ className, classNames }) => {
-  const { setTheme, resolvedTheme } = useTheme();
-
-  const isReady = resolvedTheme !== undefined;
-  const isDark = resolvedTheme === "dark";
-
-  const onChange = () => {
-    const newTheme = isDark ? "light" : "dark";
-    setTheme(newTheme);
-  };
-
-  const {
-    Component,
-    slots,
-    getBaseProps,
-    getInputProps,
-    getWrapperProps,
-  } = useSwitch({
-    isSelected: isDark,
-    "aria-label": `Switch to ${isDark ? "light" : "dark"} mode`,
-    onChange,
-  });
-
-  if (!isReady) {
-    return <IconSkeleton />;
-  }
-
-  return (
-    <Component
-      {...getBaseProps({
-        className: clsx(
-          "flex size-9 lg:size-11 max-w-auto cursor-pointer items-center justify-center rounded-sm border border-solid border-neutral-200 transition-opacity dark:border-neutral-700",
-          className,
-          classNames?.base
-        ),
-      })}
-    >
-      <VisuallyHidden>
-        <input {...getInputProps()} />
-      </VisuallyHidden>
-
-      <div
-        {...getWrapperProps()}
-        className={slots.wrapper({
-          class: clsx(
-            [
-              "mx-0 h-auto w-auto rounded-lg bg-transparent px-0 pt-px",
-              "flex items-center justify-center",
-              "group-data-[selected=true]:bg-transparent",
-              "fill-black/60 stroke-black/60 dark:stroke-white/60 dark:fill-white/60",
-            ],
-            classNames?.wrapper
-          ),
-        })}
-      >
-        {isDark ? <SunFilledIcon size={20} /> : <MoonFilledIcon size={20} />}
-      </div>
-    </Component>
-  );
-};
-
-export default ThemeSwitch;

+ 0 - 13
src/components/theme/theme-switch/index.tsx

@@ -1,13 +0,0 @@
-"use client";
-
-import dynamic from "next/dynamic";
-import { IconSkeleton } from "@/components/common/skeleton/IconSkeleton";
-
-const ThemeSwitcher = dynamic(() => import("./ThemeSwitch"), {
-  ssr: false,
-  loading: () => <IconSkeleton />,
-});
-
-export default function ThemeSwitcherWrapper() {
-  return <ThemeSwitcher />;
-}

+ 6 - 1
src/graphql/cart/mutations/AddProductToCart.ts

@@ -5,19 +5,20 @@ export const CREATE_ADD_PRODUCT_IN_CART = gql`
     $cartId: Int
     $productId: Int!
     $quantity: Int!
+    $variantId: Int
   ) {
     createAddProductInCart(
       input: {
         cartId: $cartId
         productId: $productId
         quantity: $quantity
+        variantId: $variantId
       }
     ) {
       addProductInCart {
         id
         cartToken
         subtotal
-        itemsCount
         taxAmount
         subtotal
         shippingAmount
@@ -45,6 +46,10 @@ export const CREATE_ADD_PRODUCT_IN_CART = gql`
         isGuest
         itemsQty
         itemsCount
+        paymentMethod
+        paymentMethodTitle
+        selectedShippingRate
+        selectedShippingRateTitle
       }
     }
   }

+ 7 - 2
src/graphql/cart/mutations/CreateMergeCart.ts

@@ -11,10 +11,15 @@ export const CREATE_MERGE_CART = gql`
     ) {
       mergeCart {
         id
-         taxAmount
-         subtotal
+        itemsQty
+        taxAmount
+        subtotal
         shippingAmount
         grandTotal
+        paymentMethod
+        paymentMethodTitle
+        selectedShippingRate
+        selectedShippingRateTitle
         items {
           edges {
             node {

+ 6 - 2
src/graphql/cart/mutations/UpdateCartItems.ts

@@ -14,7 +14,7 @@ export const UPDATE_CART_ITEM = gql
     ) {
       updateCartItem {
         id
-         taxAmount
+        taxAmount
         shippingAmount
         subtotal
         grandTotal
@@ -35,8 +35,12 @@ export const UPDATE_CART_ITEM = gql
             }
           }
         }
-          itemsQty
+        itemsQty
         grandTotal
+        paymentMethod
+        paymentMethodTitle
+        selectedShippingRate
+        selectedShippingRateTitle
       }
     }
   }

+ 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
         }
       }
     }

+ 2 - 0
src/graphql/checkout/mutations/CreateCheckoutOrder.ts

@@ -8,6 +8,8 @@ export const CREATE_CHECKOUT_ORDER = gql`
       checkoutOrder {
         id
         orderId
+        message
+        success
       }
     }
   }

+ 1 - 0
src/graphql/checkout/queries/GetCheckoutPaymentMethods.ts

@@ -4,6 +4,7 @@ export const GET_CHECKOUT_PAYMENT_METHODS = gql`
   query CheckoutPaymentMethods {
     collectionPaymentMethods {
       id
+      _id
       method
       title
       description

+ 1 - 1
src/graphql/index.ts

@@ -6,4 +6,4 @@ export * from "./cart/mutations";
 export * from "./checkout/queries";
 export * from "./checkout/mutations";
 export * from "./types";
-export { graphqlRequest, graphqlRequestNoCache } from "../lib/graphql-fetch";
+

+ 79 - 0
src/lib/ApolloClientBrowser.ts

@@ -0,0 +1,79 @@
+import { cache as reactCache } from "react";
+import { HttpLink,ApolloLink } from "@apollo/client";
+import { SetContextLink } from "@apollo/client/link/context";
+import {
+  ApolloClient,
+  InMemoryCache,
+} from "@apollo/client-integration-nextjs";
+import { getSession } from "next-auth/react";
+import { getCartToken } from "@/utils/getCartToken";
+import { BagistoSession } from "@/types/types";
+
+// 这里注册的是客户端使用的apollo client
+
+let sessionCache: { session: BagistoSession | null; timestamp: number } | null = null;
+const SESSION_CACHE_TTL = 5000;
+
+const getSessionForRequest = reactCache(async () => {
+  return (await getSession()) as BagistoSession | null;
+});
+
+async function getCachedSession(): Promise<BagistoSession | null> {
+  if (typeof window === "undefined") {
+    return getSessionForRequest();
+  }
+
+  const now = Date.now();
+
+  if (sessionCache && now - sessionCache.timestamp < SESSION_CACHE_TTL) {
+    return sessionCache.session;
+  }
+  const session = (await getSession()) as BagistoSession | null;
+  sessionCache = { session, timestamp: now };
+  return session;
+}
+
+export default function makeClient() {
+    const httpLink = new HttpLink({
+        uri: "/api/graphql",
+        credentials: "include",
+
+        /*fetchOptions: {
+            // Optional: Next.js-specific fetch options
+            // Note: This doesn't work with `export const dynamic = "force-static"`
+        },*/
+  });
+
+
+  const authLink = new SetContextLink(async (prevContext, operation) => {
+      
+      const session = await getCachedSession();
+      const userToken = session?.user?.accessToken;
+      const guestToken = !userToken ? getCartToken() : null;
+      const token = userToken || guestToken;
+  
+      return {
+        headers: {
+          ...prevContext.headers,
+          ...(token && { Authorization: `Bearer ${token}` }),
+          "Content-Type": "application/json",
+        },
+      };
+    });
+
+    const link = ApolloLink.from([authLink, httpLink]);
+    return new ApolloClient({
+        // ssrMode,
+        link,
+        cache: new InMemoryCache(),
+        // defaultOptions: {
+        //     watchQuery: {
+        //         fetchPolicy: "cache-first",
+        //         nextFetchPolicy: "cache-first",
+        //     },
+        //     query: {
+        //         fetchPolicy: "cache-first",
+        //     },
+        // },
+    });
+}

+ 47 - 0
src/lib/ApolloClientServer.ts

@@ -0,0 +1,47 @@
+import { GRAPHQL_URL } from "@/utils/constants";
+import { HttpLink,ApolloLink } from "@apollo/client";
+import { SetContextLink } from "@apollo/client/link/context";
+import {
+  registerApolloClient,
+  ApolloClient,
+  InMemoryCache,
+} from "@apollo/client-integration-nextjs";
+
+// apollo client 底层还是调的nextjs的fetch函数。
+// apollo client 的缓存策略是建立在nextjs缓存策略之上的,相当于两个沙盒
+// 这里注册的是服务端的apollo client。项目中服务端使用的apollo client只是用于query 查询,而且不需要Authorization
+
+export const { getClient, query, PreloadQuery } = registerApolloClient(() => {
+
+    const httpLink = new HttpLink({
+        uri: GRAPHQL_URL,
+        credentials: "include",
+        /*
+      fetchOptions: {
+        // Optional: Next.js-specific fetch options for caching and revalidation
+        // See: https://nextjs.org/docs/app/api-reference/functions/fetch
+      },
+      */
+    });
+    const authLink = new SetContextLink((prevContext, operation) => {
+        const storefrontKey =
+            process.env.BAGISTO_STOREFRONT_KEY ||
+            process.env.NEXT_PUBLIC_BAGISTO_STOREFRONT_KEY ||
+            "";
+
+        return {
+            headers: {
+            ...prevContext.headers,
+            "X-STOREFRONT-KEY": storefrontKey,
+            },
+        };
+
+    });
+
+    const link = ApolloLink.from([authLink, httpLink]);
+
+  return new ApolloClient({
+    cache: new InMemoryCache(),
+    link: link
+  });
+});

+ 0 - 103
src/lib/apollo-client.ts

@@ -1,103 +0,0 @@
-import { cache as reactCache } from "react";
-import { GRAPHQL_URL } from "@/utils/constants";
-import {
-  ApolloClient,
-  HttpLink,
-  InMemoryCache,
-  from,
-} from "@apollo/client";
-import { setContext } from "@apollo/client/link/context";
-import { getSession } from "next-auth/react";
-import { getCartToken } from "@/utils/getCartToken";
-import { BagistoSession } from "@/types/types";
-
-
-
-let sessionCache: { session: BagistoSession | null; timestamp: number } | null = null;
-const SESSION_CACHE_TTL = 5000;
-
-const getSessionForRequest = reactCache(async () => {
-  return (await getSession()) as BagistoSession | null;
-});
-
-async function getCachedSession(): Promise<BagistoSession | null> {
-  if (typeof window === "undefined") {
-    return getSessionForRequest();
-  }
-
-  const now = Date.now();
-
-  if (sessionCache && now - sessionCache.timestamp < SESSION_CACHE_TTL) {
-    return sessionCache.session;
-  }
-  const session = (await getSession()) as BagistoSession | null;
-  sessionCache = { session, timestamp: now };
-  return session;
-}
-
-function createApolloClient() {
-  const ssrMode = typeof window === "undefined";
-  const cache = new InMemoryCache();
-
-  // 服务端渲染的话请求的地址是bagisto后端地址,否则的话请求的是nextjs的代理接口
-  const httpLink = new HttpLink({
-    uri: ssrMode ? GRAPHQL_URL : "/api/graphql",
-    credentials: "include",
-  });
-
-  // 如果是服务端渲染,因为直接调用bagisto后端接口,需要设置storefront key
-  const authLink = setContext(async (_, { headers }) => {
-    if (ssrMode) {
-      const storefrontKey =
-        process.env.BAGISTO_STOREFRONT_KEY ||
-        process.env.NEXT_PUBLIC_BAGISTO_STOREFRONT_KEY ||
-        "";
-
-      return {
-        headers: {
-          ...headers,
-          "X-STOREFRONT-KEY": storefrontKey,
-        },
-      };
-    }
-
-    const session = await getCachedSession();
-    const userToken = session?.user?.accessToken;
-    const guestToken = !userToken ? getCartToken() : null;
-    const token = userToken || guestToken;
-
-    return {
-      headers: {
-        ...headers,
-        ...(token && { Authorization: `Bearer ${token}` }),
-        "Content-Type": "application/json",
-      },
-    };
-  });
-
-  const link = from([authLink, httpLink]);
-
-  return new ApolloClient({
-    ssrMode,
-    link,
-    cache,
-    defaultOptions: {
-      watchQuery: {
-        fetchPolicy: ssrMode ? "network-only" : "cache-first",
-        nextFetchPolicy: ssrMode ? "network-only" : "cache-first",
-      },
-      query: {
-        fetchPolicy: ssrMode ? "network-only" : "cache-first",
-      },
-    },
-  });
-}
-
-const getClient = reactCache(createApolloClient);
-
-export default function initializeApollo() {
-  if (typeof window === "undefined") {
-    return getClient();
-  }
-  return createApolloClient();
-}

+ 97 - 52
src/lib/graphql-fetch.ts

@@ -1,14 +1,61 @@
 import { unstable_cache } from "next/cache";
 import { print, type DocumentNode } from "graphql";
 import {
-  type ApolloQueryResult,
   type OperationVariables,
+  ApolloClient
 } from "@apollo/client";
-import makeClient from "./apollo-client";
 
+import {getClient} from "@/lib/ApolloClientServer";
 
+import {
+  CombinedGraphQLErrors,
+  CombinedProtocolErrors,
+  LocalStateError,
+  ServerError,
+  ServerParseError,
+  UnconventionalError,
+} from "@apollo/client/errors";
+// Comprehensive error handling example. https://www.apollographql.com/docs/react/data/error-handling
+function handleError(error: unknown) {
+  let res = '';
+  if (CombinedGraphQLErrors.is(error)) {
+    // Handle GraphQL errors 发起请求前graphql的致命错误 直接抛出
+    throw error;
+  } /*else if (CombinedProtocolErrors.is(error)) {
+    // Handle multipart subscription protocol errors
+    error.errors.forEach((protocolError) => {
+      console.log(protocolError.message);
+      console.log(protocolError.extensions);
+    });
+  } */else if (LocalStateError.is(error)) { // 使用@client 报错时
+    // Handle errors thrown by the `LocalState` class
+    throw error;
+
+  } else if (ServerError.is(error)) {
+    // Handle server HTTP errors
+    res = `Response body: ${error.statusCode} / ${error.message}`;
+    // Handle unauthorized access
+    //  if (error.statusCode === 401) {
+    //    
+    //  }
+  } else if (ServerParseError.is(error)) {
+    // Handle JSON parse 
+     throw error;
+  } else if (UnconventionalError.is(error)) {
+    // Handle errors thrown by irregular types
+    throw error;
 
+  } else {
+    // Handle other errors
+    throw error;
+  }
+  return res;
+}
 
+export interface GraphqlRequestResult<TData = unknown> {
+  data: TData | null;
+  error: string | undefined;
+}
 
 export type CacheLifePreset =
   | "seconds"
@@ -46,7 +93,7 @@ export function getRevalidateTime(
 
 
 
-const queryPrintMemo = new WeakMap<DocumentNode, string>();
+
 
 export function stableStringify(value: unknown): string {
   if (value === null || typeof value !== "object") {
@@ -88,18 +135,44 @@ export async function graphqlRequest<
   query: DocumentNode,
   variables?: TVariables,
   options?: GraphQLRequestOptions
-): Promise<TData> {
+): Promise<GraphqlRequestResult<TData>> {
+  const client = getClient();
+  let resData, resError;
+  const revalidate = getRevalidateTime(options?.life);
+  let queryOption: ApolloClient.QueryOptions<TData> = {
+    query,
+    variables,
+    fetchPolicy: "network-only",
+    context: {
+      fetchOptions: {
+        next: {
+          revalidate,
+          tags: options?.tags,
+        },
+      },
+    }
+  };
+
   if (options?.noCache) {
-    const client = makeClient();
-    const result: ApolloQueryResult<TData> =
-      await client.query({
-        query,
-        variables,
-        context: options?.context,
-        fetchPolicy: options?.fetchPolicy ?? "no-cache",
-      });
-
-    return result.data;
+    /***
+     * client.query的参数是一个对象:
+     * {query, variables, context, fetchPolicy, errorPolicy}
+     * context.fetchOptions 可以设置nextjs fetch的缓存策略 https://www.apollographql.com/docs/react/integrations/nextjs
+     * context: {
+        fetchOptions: {
+          next: {
+            revalidate: 60,      // 对应 life: 'minutes'
+            tags: ['posts'],     // 对应 tags 选项
+          },
+        },
+      },
+     */
+    queryOption = {
+      query,
+      variables,
+      context: options?.context,
+      fetchPolicy: "no-cache", // 跳过 apollo client 的缓存,直接调fetch
+    };
   }
 
   if (options?.context) {
@@ -107,44 +180,16 @@ export async function graphqlRequest<
       "graphqlRequest: Caching with `context` is unsafe. Use noCache instead."
     );
   }
-
-  let queryString: string;
-  const cachedQueryString = queryPrintMemo.get(query);
-
-  if (cachedQueryString) {
-    queryString = cachedQueryString;
-  } else {
-    queryString = print(query);
-    queryPrintMemo.set(query, queryString);
+  try {
+      // Promise-based APIs (e.g. client.query, client.mutate) - Errors either reject the promise or are returned in the result as the error field.
+      // 如果错误被reject 则会进入catch
+      const result: ApolloClient.QueryResult<TData> = await client.query(queryOption);
+      console.log('graphqlRequest network-only result ---- ', result);
+      resData = result.data || null;
+      return {data: resData, error: result.error? result.error.message : '' };
+  } catch (error) {
+    throw error;
   }
-
-  const cacheKey = `graphql:${stableStringify({
-    query: queryString,
-    variables,
-  })}`;
-
-  const revalidate = getRevalidateTime(options?.life);
-
-  const cachedQuery = unstable_cache(
-    async (): Promise<TData> => {
-      const client = makeClient();
-      const result: ApolloQueryResult<TData> =
-        await client.query({
-          query,
-          variables,
-          fetchPolicy: "network-only",
-        });
-
-      return result.data;
-    },
-    [cacheKey],
-    {
-      tags: options?.tags,
-      revalidate,
-    }
-  );
-
-  return cachedQuery();
 }
 
 
@@ -159,7 +204,7 @@ export async function graphqlRequestNoCache<
     GraphQLRequestOptions,
     "noCache" | "tags" | "life"
   >
-): Promise<TData> {
+): Promise<GraphqlRequestResult<TData>> {
   return graphqlRequest<TData, TVariables>(
     query,
     variables,

+ 55 - 0
src/lib/restApiClient.ts

@@ -0,0 +1,55 @@
+'use client';
+/**前端客户端组件调rest api 接口 */
+import { getSession } from "next-auth/react";
+import { getCartToken } from "@/utils/getCartToken";
+import { BagistoSession } from "@/types/types";
+
+
+let sessionCache: { session: BagistoSession | null; timestamp: number } | null = null;
+const SESSION_CACHE_TTL = 5000;
+
+async function getCachedSession(): Promise<BagistoSession | null> {
+
+  const now = Date.now();
+
+  if (sessionCache && now - sessionCache.timestamp < SESSION_CACHE_TTL) {
+    return sessionCache.session;
+  }
+  //When called, getSession() will send a request to /api/auth/session and returns a promise with a session object, or null if no session exists.
+  const session = (await getSession()) as BagistoSession | null;
+  sessionCache = { session, timestamp: now };
+  return session;
+}
+
+export async function clientFetch(apiUrl: string, options: RequestInit) {
+    // 请求的是nextjs的代理接口
+
+    const session = await getCachedSession();
+    const userToken = session?.user?.accessToken;
+    const guestToken = !userToken ? getCartToken() : null;
+    const token = userToken || guestToken;
+
+    const headers = {
+        ...(token && { Authorization: `Bearer ${token}` }),
+        "Content-Type": "application/json",
+    };
+    if(options.headers) {
+        options.headers = Object.assign(headers, options.headers);
+    } else {
+        options.headers = headers;
+    }
+    
+    const response = await fetch(apiUrl, options);
+    // console.log('response -- ', response);
+    if(response.ok) {
+        let result = await response.json();
+        return result;
+    } else {
+        return {
+            status: response.status,
+            statusText: response.statusText,
+            data: await response.text(),
+        }
+    }
+    
+}

+ 4 - 4
src/providers/ApolloWrapper.tsx

@@ -1,13 +1,13 @@
 "use client";
 
-import { ApolloProvider } from "@apollo/client/react";
+import { ApolloNextAppProvider } from "@apollo/client-integration-nextjs";
 import { ReactNode, useMemo } from "react";
-import initializeApollo  from "../lib/apollo-client";
+import makeClient from "@/lib/ApolloClientBrowser";
 
 const ApolloWrapper = ({ children }: { children: ReactNode }) => {
-  const client = useMemo(() => initializeApollo(), []);
+  // const client = useMemo(() => initializeApollo(), []);
   
-  return <ApolloProvider client={client}>{children}</ApolloProvider>;
+  return <ApolloNextAppProvider makeClient={makeClient}>{children}</ApolloNextAppProvider>;
 };
 
 export  {ApolloWrapper};

+ 3 - 11
src/providers/ThemeProvider.tsx

@@ -1,15 +1,14 @@
 "use client";
 
-import type { ThemeProviderProps } from "next-themes";
+
 import * as React from "react";
 import { HeroUIProvider } from "@heroui/system";
 import { useRouter } from "next/navigation";
-import { ThemeProvider as NextThemesProvider } from "next-themes";
+
 
 
 export interface ProvidersProps {
   children: React.ReactNode;
-  themeProps?: ThemeProviderProps;
 }
 
 
@@ -22,18 +21,11 @@ declare module "@react-types/shared" {
 }
 
 
-export function ThemeProvider({ children, themeProps }: ProvidersProps) {
+export function ThemeProvider({ children }: ProvidersProps) {
   const router = useRouter();
   return (
     <HeroUIProvider navigate={router.push}>
-      <NextThemesProvider  
-        attribute="class"
-        defaultTheme='light'
-        enableSystem={false}
-        {...themeProps}
-      >
         {children}
-      </NextThemesProvider>
     </HeroUIProvider>
   );
 }

+ 1 - 1
src/proxy.ts

@@ -10,7 +10,7 @@ export async function proxy(request: NextRequest) {
     if (restrictedPaths.some((path) => pathname.startsWith(path))) {
         const token = await getToken({
             req: request,
-            secret: process.env.NEXT_PUBLIC_NEXT_AUTH_SECRET
+            secret: process.env.NEXTAUTH_SECRET
         })
 
         if (token) {

+ 34 - 0
src/store/slices/addToCartDialogSlice.ts

@@ -0,0 +1,34 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit';
+import { SingleProductResponse, ProductNode } from "@/components/catalog/type";
+
+interface DialogState {
+  isOpen: boolean;
+  product: ProductNode | null;
+}
+
+const initialState: DialogState = {
+  isOpen: false,
+  product: null,
+};
+
+const addToCartDialogSlice = createSlice({
+  name: 'addToCartDialog',
+  initialState,
+  reducers: {
+    openAddToCartDialog(state, action: PayloadAction<ProductNode>) {
+      state.isOpen = true;
+      state.product = action.payload;
+    },
+    closeAddToCartDialog(state) {
+      state.isOpen = false;
+      // 可以保留 product 用于退出动画(建议动画完成后再清空,这里简单处理)
+      // 如果需要等动画结束,可以在组件中延迟 dispatch(clearProduct())
+    },
+    clearAddToCartProduct(state) {
+      state.product = null;
+    },
+  },
+});
+
+export const { openAddToCartDialog, closeAddToCartDialog, clearAddToCartProduct } = addToCartDialogSlice.actions;
+export default addToCartDialogSlice.reducer;

+ 2 - 2
src/store/slices/cart-slice.ts

@@ -2,7 +2,7 @@ import { AddressDataTypes } from "@/types/types";
 import { PayloadAction, createSlice } from "@reduxjs/toolkit";
 
 export interface Cart {
-  id: number;
+  id: string;
   itemsQty: number;
   taxAmount: number;
   shippingAmount: number;
@@ -10,7 +10,7 @@ export interface Cart {
   items: any;
   paymentMethod: string;
   paymentMethodTitle: string;
-  shippingMethod: string;
+  // shippingMethod: string; // shippingMethod的值其实与selectedShippingRate的值相等
   selectedShippingRate: string;
   selectedShippingRateTitle: string;
 }

+ 2 - 0
src/store/store.ts

@@ -2,11 +2,13 @@ import { configureStore } from "@reduxjs/toolkit";
 
 import cartSlice from "./slices/cart-slice";
 import userSlice from "./slices/user-slice";
+import addToCartDialogReducer from "./slices/addToCartDialogSlice";
 
 export const store = configureStore({
   reducer: {
     cartDetail: cartSlice,
     user: userSlice,
+    addToCartDialog: addToCartDialogReducer,
   },
 });
 

+ 14 - 0
src/types/cart/type.ts

@@ -36,6 +36,7 @@ export interface ReadCart {
   grandTotal: number;
   shippingAmount: number;
   selectedShippingRate: string | null;
+  selectedShippingRateTitle: string | null;
   subtotal: number;
   itemsQty: number;
   isGuest: boolean;
@@ -95,6 +96,10 @@ export interface AddProductInCart {
   success: boolean;
   message: string;
   items: AddToCartItemsConnection;
+  paymentMethod: string | null;
+  paymentMethodTitle: string | null;
+  selectedShippingRate: string | null;
+  selectedShippingRateTitle: string | null;
 }
 
 export interface CreateAddProductInCart {
@@ -165,10 +170,15 @@ export interface MergeCartItemsConnection {
 }
 export interface MergeCart {
   id: string;
+  itemsQty: number;
   taxAmount: number;
   subtotal: number;
   shippingAmount: number;
   grandTotal: number;
+  paymentMethod: string | null;
+  paymentMethodTitle: string | null;
+  selectedShippingRate: string | null;
+  selectedShippingRateTitle: string | null;
   items: MergeCartItemsConnection;
 }
 export interface CreateMergeCartPayload {
@@ -267,6 +277,10 @@ export interface UpdateCartItem {
   grandTotal: number;
   itemsQty: number;
   items: UpdateCartItemsConnection;
+  paymentMethod: string | null;
+  paymentMethodTitle: string | null;
+  selectedShippingRate: string | null;
+  selectedShippingRateTitle: string | null;
 }
 export interface CreateUpdateCartItemPayload {
   updateCartItem: UpdateCartItem;

+ 5 - 8
src/types/checkout/type.ts

@@ -1,12 +1,6 @@
 export type AddressType = "cart_billing" | "cart_shipping";
 
-export interface GetCheckoutAddressesResponse {
-  collectionGetCheckoutAddresses: CheckoutAddressConnection;
-}
 
-export interface CheckoutAddressConnection {
-  edges: CheckoutAddressEdge[];
-}
 
 
 
@@ -155,6 +149,8 @@ export interface GetCheckoutShippingRatesOperation {
 export interface CheckoutOrder {
   id: string;
   orderId: string;
+  message: string;
+  success: boolean;
 }
 export interface CreateCheckoutOrderPayload {
   checkoutOrder: CheckoutOrder;
@@ -184,11 +180,12 @@ export interface CheckoutAddress {
   useForShipping: boolean;
 }
 
-export interface CheckoutAddressesConnection {
+
+export interface CheckoutAddressConnection {
   edges: CheckoutAddressEdge[];
 }
 export interface GetCheckoutAddressesData {
-  collectionGetCheckoutAddresses: CheckoutAddressesConnection;
+  collectionGetCheckoutAddresses: CheckoutAddressConnection;
 }
 export interface GetCheckoutAddressesOperation {
   data: GetCheckoutAddressesData;

+ 0 - 57
src/utils/LRUCache.ts

@@ -1,57 +0,0 @@
-interface CacheEntry<T> {
-    value: T;
-    timestamp: number;
-}
-export class LRUCache<T> {
-    private cache: Map<string, CacheEntry<T>>;
-    private maxSize: number;
-    private ttl: number;
-
-    constructor(maxSize: number = 100, ttlMinutes: number = 10) {
-        this.cache = new Map();
-        this.maxSize = maxSize;
-        this.ttl = ttlMinutes * 60 * 1000;
-    }
-
-    get(key: string): T | null {
-        const entry = this.cache.get(key);
-
-        if (!entry) {
-            return null;
-        }
-
-        if (Date.now() - entry.timestamp > this.ttl) {
-            this.cache.delete(key);
-            return null;
-        }
-
-        this.cache.delete(key);
-        this.cache.set(key, entry);
-
-        return entry.value;
-    }
-
-    set(key: string, value: T): void {
-        if (this.cache.has(key)) {
-            this.cache.delete(key);
-        }
-        if (this.cache.size >= this.maxSize) {
-            const firstKey = this.cache.keys().next().value;
-            if (firstKey) {
-                this.cache.delete(firstKey);
-            }
-        }
-        this.cache.set(key, {
-            value,
-            timestamp: Date.now(),
-        });
-    }
-
-    clear(): void {
-        this.cache.clear();
-    }
-
-    size(): number {
-        return this.cache.size;
-    }
-}

+ 0 - 0
src/utils/actions.ts


Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio