From 2857d34f4d47b4695452c37379bc7be18c245713 Mon Sep 17 00:00:00 2001 From: Jelaletdin12 Date: Sat, 22 Nov 2025 20:59:28 +0500 Subject: [PATCH] connect some api --- app/[locale]/layout.tsx | 1 - app/[locale]/me/page.tsx | 2 +- app/[locale]/openStore/page.tsx | 498 ++++++++-------- app/[locale]/orders/page.tsx | 2 +- components/ProductCard.tsx | 214 ------- components/layout/ui/CategoryMenu.tsx | 2 +- components/ui/carousel.tsx | 241 ++++++++ eslint.config.mjs | 48 +- .../category/components/CategoryClient.tsx | 542 +++++++----------- features/category/hooks/useCategories.ts | 66 ++- features/home/components/HomePage.tsx | 2 +- features/home/components/ProductCard.tsx | 232 ++++++++ features/home/components/ProductGrid.tsx | 32 +- features/openStore/hooks/useOpenStore.ts | 47 ++ .../{orders-page-client.tsx => OrderPage.tsx} | 0 .../components/ProductPageContent.tsx | 68 ++- ...client-page.tsx => ProfilePageContent.tsx} | 0 lib/hooks/index.ts | 2 +- lib/hooks/useOpenStore.ts | 38 -- lib/types/api.ts | 31 + next.config.ts | 3 - package-lock.json | 54 +- package.json | 1 + middleware.ts => proxy.ts | 0 24 files changed, 1233 insertions(+), 893 deletions(-) delete mode 100644 components/ProductCard.tsx create mode 100644 components/ui/carousel.tsx create mode 100644 features/home/components/ProductCard.tsx create mode 100644 features/openStore/hooks/useOpenStore.ts rename features/orders/components/{orders-page-client.tsx => OrderPage.tsx} (100%) rename features/profile/components/{client-page.tsx => ProfilePageContent.tsx} (100%) delete mode 100644 lib/hooks/useOpenStore.ts rename middleware.ts => proxy.ts (100%) diff --git a/app/[locale]/layout.tsx b/app/[locale]/layout.tsx index 14c9bf5..251cf14 100644 --- a/app/[locale]/layout.tsx +++ b/app/[locale]/layout.tsx @@ -53,7 +53,6 @@ export default async function RootLayout({ children, params }: Props) { - {/* AuthWrapper handles guest token initialization */}
{children} diff --git a/app/[locale]/me/page.tsx b/app/[locale]/me/page.tsx index db89644..c794193 100644 --- a/app/[locale]/me/page.tsx +++ b/app/[locale]/me/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next" -import ClientProfilePage from "../../../features/profile/components/client-page" +import ClientProfilePage from "../../../features/profile/components/ProfilePageContent" export const metadata: Metadata = { title: "My Profile | E-Commerce", diff --git a/app/[locale]/openStore/page.tsx b/app/[locale]/openStore/page.tsx index 96c4a45..667729b 100644 --- a/app/[locale]/openStore/page.tsx +++ b/app/[locale]/openStore/page.tsx @@ -1,274 +1,274 @@ -// "use client" +"use client" -// import type React from "react" -// import { useState } from "react" -// import { Upload } from "lucide-react" -// import { Button } from "@/components/ui/button" -// import { Input } from "@/components/ui/input" -// import { Label } from "@/components/ui/label" -// import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -// import { useOpenStore } from "@/lib/hooks" -// import { useToast } from "@/hooks/use-toast" +import type React from "react" +import { useState } from "react" +import { Upload } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useOpenStore } from "@/lib/hooks" +import { useToast } from "@/hooks/use-toast" -// interface OpenStorePageProps { -// locale?: string -// translations?: { -// title: string -// firstName: string -// lastName: string -// email: string -// phone: string -// uploadPatent: string -// submit: string -// selectedFile: string -// firstNameRequired: string -// lastNameRequired: string -// emailInvalid: string -// phoneInvalid: string -// fileRequired: string -// fileSizeError: string -// fileTypeError: string -// } -// } +interface OpenStorePageProps { + locale?: string + translations?: { + title: string + firstName: string + lastName: string + email: string + phone: string + uploadPatent: string + submit: string + selectedFile: string + firstNameRequired: string + lastNameRequired: string + emailInvalid: string + phoneInvalid: string + fileRequired: string + fileSizeError: string + fileTypeError: string + } +} -// interface FormData { -// firstName: string -// lastName: string -// email: string -// phone: string -// file: File | null -// } +interface FormData { + firstName: string + lastName: string + email: string + phone: string + file: File | null +} -// interface FormErrors { -// firstName?: string -// lastName?: string -// email?: string -// phone?: string -// file?: string -// } +interface FormErrors { + firstName?: string + lastName?: string + email?: string + phone?: string + file?: string +} -// export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) { -// const [formData, setFormData] = useState({ -// firstName: "", -// lastName: "", -// email: "", -// phone: "+993", -// file: null, -// }) -// const [errors, setErrors] = useState({}) -// const [fileName, setFileName] = useState("") +export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) { + const [formData, setFormData] = useState({ + firstName: "", + lastName: "", + email: "", + phone: "+993", + file: null, + }) + const [errors, setErrors] = useState({}) + const [fileName, setFileName] = useState("") -// const { mutate: submitOpenStore, isPending: loading } = useOpenStore() -// const { toast } = useToast() + const { mutate: submitOpenStore, isPending: loading } = useOpenStore() + const { toast } = useToast() -// const t = translations || { -// title: "Форма подачи заявления на открытие магазина", -// firstName: "Имя", -// lastName: "Фамилия", -// email: "Email", -// phone: "Телефон", -// uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)", -// submit: "Отправить", -// selectedFile: "Выбранный файл", -// firstNameRequired: "Имя обязательно", -// lastNameRequired: "Фамилия обязательна", -// emailInvalid: "Некорректный email", -// phoneInvalid: "Некорректный номер телефона", -// fileRequired: "Патент обязателен", -// fileSizeError: "Файл слишком большой (макс. 25MB)", -// fileTypeError: "Только PDF и JPG документы", -// } + const t = translations || { + title: "Форма подачи заявления на открытие магазина", + firstName: "Имя", + lastName: "Фамилия", + email: "Email", + phone: "Телефон", + uploadPatent: "Загрузите патент на розничную торговлю (PDF, JPG)", + submit: "Отправить", + selectedFile: "Выбранный файл", + firstNameRequired: "Имя обязательно", + lastNameRequired: "Фамилия обязательна", + emailInvalid: "Некорректный email", + phoneInvalid: "Некорректный номер телефона", + fileRequired: "Патент обязателен", + fileSizeError: "Файл слишком большой (макс. 25MB)", + fileTypeError: "Только PDF и JPG документы", + } -// const validateForm = (): boolean => { -// const newErrors: FormErrors = {} + const validateForm = (): boolean => { + const newErrors: FormErrors = {} -// if (!formData.firstName.trim()) { -// newErrors.firstName = t.firstNameRequired -// } + if (!formData.firstName.trim()) { + newErrors.firstName = t.firstNameRequired + } -// if (!formData.lastName.trim()) { -// newErrors.lastName = t.lastNameRequired -// } + if (!formData.lastName.trim()) { + newErrors.lastName = t.lastNameRequired + } -// const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ -// if (!emailRegex.test(formData.email)) { -// newErrors.email = t.emailInvalid -// } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(formData.email)) { + newErrors.email = t.emailInvalid + } -// const phoneRegex = /^\+?[0-9]{6,15}$/ -// if (!phoneRegex.test(formData.phone)) { -// newErrors.phone = t.phoneInvalid -// } + const phoneRegex = /^\+?[0-9]{6,15}$/ + if (!phoneRegex.test(formData.phone)) { + newErrors.phone = t.phoneInvalid + } -// if (!formData.file) { -// newErrors.file = t.fileRequired -// } else { -// const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"] -// if (!allowedTypes.includes(formData.file.type)) { -// newErrors.file = t.fileTypeError -// } -// if (formData.file.size > 25 * 1024 * 1024) { -// newErrors.file = t.fileSizeError -// } -// } + if (!formData.file) { + newErrors.file = t.fileRequired + } else { + const allowedTypes = ["image/jpeg", "image/jpg", "application/pdf"] + if (!allowedTypes.includes(formData.file.type)) { + newErrors.file = t.fileTypeError + } + if (formData.file.size > 25 * 1024 * 1024) { + newErrors.file = t.fileSizeError + } + } -// setErrors(newErrors) -// return Object.keys(newErrors).length === 0 -// } + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } -// const handleInputChange = (e: React.ChangeEvent) => { -// const { name, value } = e.target -// setFormData((prev) => ({ ...prev, [name]: value })) -// if (errors[name as keyof FormErrors]) { -// setErrors((prev) => ({ ...prev, [name]: undefined })) -// } -// } + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target + setFormData((prev) => ({ ...prev, [name]: value })) + if (errors[name as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [name]: undefined })) + } + } -// const handleFileChange = (e: React.ChangeEvent) => { -// const file = e.target.files?.[0] -// if (file) { -// setFormData((prev) => ({ ...prev, file })) -// setFileName(file.name) -// if (errors.file) { -// setErrors((prev) => ({ ...prev, file: undefined })) -// } -// } -// } + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setFormData((prev) => ({ ...prev, file })) + setFileName(file.name) + if (errors.file) { + setErrors((prev) => ({ ...prev, file: undefined })) + } + } + } -// const handleSubmit = (e: React.FormEvent) => { -// e.preventDefault() + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() -// if (!validateForm()) return + if (!validateForm()) return -// if (formData.file) { -// submitOpenStore( -// { -// firstName: formData.firstName, -// lastName: formData.lastName, -// email: formData.email, -// phone: formData.phone, -// patentFile: formData.file, -// }, -// { -// onSuccess: () => { -// toast({ -// title: "Success", -// description: "Your store request has been submitted successfully", -// }) -// setFormData({ -// firstName: "", -// lastName: "", -// email: "", -// phone: "+993", -// file: null, -// }) -// setFileName("") -// }, -// onError: (error: any) => { -// toast({ -// title: "Error", -// description: error?.message || "Failed to submit store request", -// variant: "destructive", -// }) -// }, -// }, -// ) -// } -// } + if (formData.file) { + submitOpenStore( + { + firstName: formData.firstName, + lastName: formData.lastName, + email: formData.email, + phone: formData.phone, + patentFile: formData.file, + }, + { + onSuccess: () => { + toast({ + title: "Success", + description: "Your store request has been submitted successfully", + }) + setFormData({ + firstName: "", + lastName: "", + email: "", + phone: "+993", + file: null, + }) + setFileName("") + }, + onError: (error: any) => { + toast({ + title: "Error", + description: error?.message || "Failed to submit store request", + variant: "destructive", + }) + }, + }, + ) + } + } -// return ( -//
-// -// -// {t.title} -// Заполните форму для подачи заявления -// -// -//
-// {/* First Name */} -//
-// -// -// {errors.firstName &&

{errors.firstName}

} -//
+ return ( +
+ + + {t.title} + Заполните форму для подачи заявления + + + + {/* First Name */} +
+ + + {errors.firstName &&

{errors.firstName}

} +
-// {/* Last Name */} -//
-// -// -// {errors.lastName &&

{errors.lastName}

} -//
+ {/* Last Name */} +
+ + + {errors.lastName &&

{errors.lastName}

} +
-// {/* Email */} -//
-// -// -// {errors.email &&

{errors.email}

} -//
+ {/* Email */} +
+ + + {errors.email &&

{errors.email}

} +
-// {/* Phone */} -//
-// -// -// {errors.phone &&

{errors.phone}

} -//
+ {/* Phone */} +
+ + + {errors.phone &&

{errors.phone}

} +
-// {/* File Upload */} -//
-// -//
-// -// -// {fileName && ( -//

-// {t.selectedFile}: {fileName} -//

-// )} -// {errors.file &&

{errors.file}

} -//
-//
+ {/* File Upload */} +
+ +
+ + + {fileName && ( +

+ {t.selectedFile}: {fileName} +

+ )} + {errors.file &&

{errors.file}

} +
+
-// {/* Submit Button */} -// -// -//
-//
-//
-// ) -// } + {/* Submit Button */} + + +
+
+
+ ) +} diff --git a/app/[locale]/orders/page.tsx b/app/[locale]/orders/page.tsx index 7769de4..691cc15 100644 --- a/app/[locale]/orders/page.tsx +++ b/app/[locale]/orders/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next" -import OrdersPageClient from "../../../features/orders/components/orders-page-client" +import OrdersPageClient from "../../../features/orders/components/OrderPage" export const metadata: Metadata = { title: "My Orders | E-Commerce", diff --git a/components/ProductCard.tsx b/components/ProductCard.tsx deleted file mode 100644 index 13bbd0f..0000000 --- a/components/ProductCard.tsx +++ /dev/null @@ -1,214 +0,0 @@ -"use client"; - -import { useState, MouseEvent } from "react"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Heart, Minus, Plus } from "lucide-react"; -import Image, { StaticImageData } from "next/image"; -import { useAddToFavorites, useRemoveFromFavorites } from "@/lib/hooks"; -import { useToast } from "@/hooks/use-toast"; - -type ProductCardProps = { - id: number; - name: string; - price: number | null; - struct_price_text: string; - discount?: number | null; - discount_text?: string | null; - images: (StaticImageData | string)[]; - is_favorite: boolean; - labels?: { text: string; bg_color: string }[]; - price_color?: string; - height?: number; - width?: number; - button?: boolean; -}; - -export default function ProductCard({ - id, - name, - price, - struct_price_text, - discount, - discount_text, - images, - is_favorite, - labels = [], - price_color = "#005bff", - height = 360, - width = 280, - button = true, -}: ProductCardProps) { - const [favorite, setFavorite] = useState(is_favorite); - const [cart, setCart] = useState(false); - const [count, setCount] = useState(1); - - const { toast } = useToast(); - const { mutate: addToFavorites, isPending: isAdding } = useAddToFavorites(); - const { mutate: removeFromFavorites, isPending: isRemoving } = - useRemoveFromFavorites(); - - const handleFavorite = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - - const newFavoriteState = !favorite; - - if (newFavoriteState) { - // Добавляем в избранное - addToFavorites(id, { - onSuccess: () => { - setFavorite(true); - toast({ - title: "Товар добавлен в избранное", - }); - }, - onError: (error) => { - toast({ - title: "Ошибка", - description: error.message, - variant: "destructive", - }); - }, - }); - } else { - // Удаляем из избранного - removeFromFavorites(id, { - onSuccess: () => { - setFavorite(false); - toast({ - title: "Товар удален из избранного", - }); - }, - onError: (error) => { - toast({ - title: "Ошибка", - description: error.message, - variant: "destructive", - }); - }, - }); - } - }; - - const handleAddToCart = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setCart(true); - }; - - const handleIncrement = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setCount((c) => c + 1); - }; - - const handleDecrement = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setCount((c) => (c > 1 ? c - 1 : c)); - }; - - const isPending = isAdding || isRemoving; - - return ( - - - {/* Image Section */} -
- {images?.[0] && ( - {name} - )} - - {/* Favorite Button */} - - - {/* Labels */} - {labels?.length > 0 && ( -
- {labels.map((label) => ( - - {label.text} - - ))} -
- )} -
- - {/* Content */} - -

- {struct_price_text} -

-

{name}

-
- - {/* Buttons - закомментированы в оригинале */} - {/* {button && ( -
- {!cart ? ( - - ) : ( -
- -
- {count} -
- -
- )} -
- )} */} -
- - ); -} diff --git a/components/layout/ui/CategoryMenu.tsx b/components/layout/ui/CategoryMenu.tsx index 1e90de5..6d5df13 100644 --- a/components/layout/ui/CategoryMenu.tsx +++ b/components/layout/ui/CategoryMenu.tsx @@ -22,7 +22,7 @@ export default function CategoryMenu({ isOpen, onClose }: CategoryMenuProps) { const activeCategory = hoveredCategory !== null ? categoryList[hoveredCategory] : null return ( -
+
+type CarouselOptions = UseCarouselParameters[0] +type CarouselPlugin = UseCarouselParameters[1] + +type CarouselProps = { + opts?: CarouselOptions + plugins?: CarouselPlugin + orientation?: "horizontal" | "vertical" + setApi?: (api: CarouselApi) => void +} + +type CarouselContextProps = { + carouselRef: ReturnType[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +function Carousel({ + orientation = "horizontal", + opts, + setApi, + plugins, + className, + children, + ...props +}: React.ComponentProps<"div"> & CarouselProps) { + const [carouselRef, api] = useEmblaCarousel( + { + ...opts, + axis: orientation === "horizontal" ? "x" : "y", + }, + plugins + ) + const [canScrollPrev, setCanScrollPrev] = React.useState(false) + const [canScrollNext, setCanScrollNext] = React.useState(false) + + const onSelect = React.useCallback((api: CarouselApi) => { + if (!api) return + setCanScrollPrev(api.canScrollPrev()) + setCanScrollNext(api.canScrollNext()) + }, []) + + const scrollPrev = React.useCallback(() => { + api?.scrollPrev() + }, [api]) + + const scrollNext = React.useCallback(() => { + api?.scrollNext() + }, [api]) + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowLeft") { + event.preventDefault() + scrollPrev() + } else if (event.key === "ArrowRight") { + event.preventDefault() + scrollNext() + } + }, + [scrollPrev, scrollNext] + ) + + React.useEffect(() => { + if (!api || !setApi) return + setApi(api) + }, [api, setApi]) + + React.useEffect(() => { + if (!api) return + onSelect(api) + api.on("reInit", onSelect) + api.on("select", onSelect) + + return () => { + api?.off("select", onSelect) + } + }, [api, onSelect]) + + return ( + +
+ {children} +
+
+ ) +} + +function CarouselContent({ className, ...props }: React.ComponentProps<"div">) { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) +} + +function CarouselItem({ className, ...props }: React.ComponentProps<"div">) { + const { orientation } = useCarousel() + + return ( +
+ ) +} + +function CarouselPrevious({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +} + +function CarouselNext({ + className, + variant = "outline", + size = "icon", + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..25e65fd 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -2,17 +2,53 @@ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; -const eslintConfig = defineConfig([ +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import tanstackPlugin from "@tanstack/eslint-plugin-query"; +import importPlugin from "eslint-plugin-import"; + +export default defineConfig([ ...nextVitals, ...nextTs, - // Override default ignores of eslint-config-next. + + // Custom rules for your e-commerce project + { + files: ["**/*.{ts,tsx}"], + languageOptions: { + parser: tsParser + }, + plugins: { + "@typescript-eslint": tsPlugin, + "@tanstack/query": tanstackPlugin, + import: importPlugin + }, + rules: { + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["error"], + + "react-hooks/exhaustive-deps": "error", + + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/no-unstable-deps": "error", + + "import/order": [ + "error", + { + "groups": ["builtin", "external", "internal", "parent", "sibling", "index"], + "newlines-between": "always" + } + ], + "import/no-default-export": "error", + + "no-console": ["warn", { allow: ["warn", "error"] }] + } + }, + globalIgnores([ - // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", "next-env.d.ts", - ]), + ]) ]); - -export default eslintConfig; diff --git a/features/category/components/CategoryClient.tsx b/features/category/components/CategoryClient.tsx index 885ec7d..1f44b33 100644 --- a/features/category/components/CategoryClient.tsx +++ b/features/category/components/CategoryClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState, useMemo, useCallback } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import { SlidersHorizontal, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -15,13 +15,13 @@ import { SheetTrigger, } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Checkbox } from "@/components/ui/checkbox"; import InfiniteScroll from "react-infinite-scroll-component"; -import ProductCard from "@/components/ProductCard"; +import ProductCard from "@/features/home/components/ProductCard"; import { useCategories, - useAllCategoryProducts, - useAllCategoryProductsPaginated, - useCategoryProducts, + useCategoryFilters, + useFilteredCategoryProducts, } from "@/features/category/hooks/useCategories"; import { notFound } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -36,15 +36,12 @@ export default function CategoryPageClient({ }: CategoryPageClientProps) { const { slug, locale } = params; const router = useRouter(); - const searchParams = useSearchParams(); const [isOpen, setIsOpen] = useState(false); const t = useTranslations(); - // Fetch all categories first const { data: categoriesData, isLoading: categoriesLoading } = useCategories(); - // Find category from slug const selectedCategory = useMemo(() => { if (!categoriesData || !slug) return null; @@ -62,95 +59,167 @@ export default function CategoryPageClient({ return findBySlug(categoriesData); }, [categoriesData, slug]); - // Track subcategories - const [hasSubcategories, setHasSubcategories] = useState(false); - const [subcategoriesToShow, setSubcategoriesToShow] = useState( - [] - ); - - // Pagination state const [currentPage, setCurrentPage] = useState(1); - const [hasMore, setHasMore] = useState(true); const [allProducts, setAllProducts] = useState([]); - - // Price sorting state const [priceSort, setPriceSort] = useState< "none" | "lowToHigh" | "highToLow" >("none"); - - // Price filter state const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]); + const [selectedBrands, setSelectedBrands] = useState>(new Set()); + const [selectedFilterCategories, setSelectedFilterCategories] = useState< + Set + >(new Set()); - // Selected filters state - const [selectedFilters, setSelectedFilters] = useState< - Record> - >({ - brand: new Set(), - color: new Set(), - tag: new Set(), - }); + // Fetch filters + const { data: filtersData, isLoading: filtersLoading } = useCategoryFilters( + selectedCategory?.id, + { enabled: !!selectedCategory } + ); - // Determine if category is a subcategory - const isSubCategory = useMemo(() => { - if (!categoriesData || !selectedCategory) return false; - - const checkIsSubCategory = ( - categories: Category[], - targetId: number - ): boolean => { - for (const category of categories) { - if (category.children) { - for (const subCategory of category.children) { - if (subCategory.id === targetId) return true; - if (subCategory.children) { - const foundInNested = checkIsSubCategory([subCategory], targetId); - if (foundInNested) return true; - } - } - } - } - return false; + // Build filter params + const filterParams = useMemo(() => { + const params: any = { + page: currentPage, + limit: 6, }; - return checkIsSubCategory(categoriesData, selectedCategory.id); - }, [categoriesData, selectedCategory]); + if (selectedBrands.size > 0) { + params.brands = Array.from(selectedBrands); + } - // Fetch initial products for subcategories (first page only) - const { data: subcategoryProducts = [], isLoading: subcategoryLoading } = - useAllCategoryProducts(selectedCategory || undefined, { - enabled: !!selectedCategory && isSubCategory && currentPage === 1, + if (selectedFilterCategories.size > 0) { + params.categories = Array.from(selectedFilterCategories); + } + + if (priceRange[0] > 0) { + params.min_price = priceRange[0]; + } + + if (priceRange[1] < 10000) { + params.max_price = priceRange[1]; + } + + return params; + }, [currentPage, selectedBrands, selectedFilterCategories, priceRange]); + + // Fetch filtered products + const { + data: productsData, + isLoading: productsLoading, + isFetching, + } = useFilteredCategoryProducts( + selectedCategory?.id?.toString() || "", + filterParams, + { enabled: !!selectedCategory } + ); + + // Reset on category change + useEffect(() => { + if (selectedCategory) { + setAllProducts([]); + setCurrentPage(1); + setSelectedBrands(new Set()); + setSelectedFilterCategories(new Set()); + setPriceRange([0, 10000]); + setPriceSort("none"); + } + }, [selectedCategory?.id]); + + // Update products list + useEffect(() => { + if (productsData?.data) { + setAllProducts((prev) => { + if (currentPage === 1) { + return productsData.data; + } + const existingIds = new Set(prev.map((p) => p.id)); + const newProducts = productsData.data.filter( + (p: Product) => !existingIds.has(p.id) + ); + return [...prev, ...newProducts]; + }); + } + }, [productsData, currentPage]); + + const hasMore = useMemo(() => { + return !!productsData?.pagination?.next_page_url; + }, [productsData]); + + const loadMoreData = useCallback(() => { + if (!hasMore || isFetching) return; + setCurrentPage((prev) => prev + 1); + }, [hasMore, isFetching]); + + const sortedProducts = useMemo(() => { + const products = [...allProducts]; + if (priceSort === "lowToHigh") { + return products.sort( + (a, b) => + parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0") + ); + } + if (priceSort === "highToLow") { + return products.sort( + (a, b) => + parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0") + ); + } + return products; + }, [allProducts, priceSort]); + + const handleBrandToggle = useCallback((brandId: number) => { + setSelectedBrands((prev) => { + const newSet = new Set(prev); + if (newSet.has(brandId)) { + newSet.delete(brandId); + } else { + newSet.add(brandId); + } + return newSet; }); + setCurrentPage(1); + setAllProducts([]); + }, []); - // Fetch paginated subcategory products (page 2+) - const { - data: paginatedSubcategoryData, - isLoading: subcategoryPaginatedLoading, - } = useAllCategoryProductsPaginated(selectedCategory || undefined, { - enabled: !!selectedCategory && isSubCategory && currentPage > 1, - page: currentPage, - limit: 6, - }); + const handleCategoryToggle = useCallback((categoryId: number) => { + setSelectedFilterCategories((prev) => { + const newSet = new Set(prev); + if (newSet.has(categoryId)) { + newSet.delete(categoryId); + } else { + newSet.add(categoryId); + } + return newSet; + }); + setCurrentPage(1); + setAllProducts([]); + }, []); - // Fetch paginated category products (for non-subcategories) - const { - data: paginatedCategoryData, - isLoading: categoryPaginatedLoading, - isFetching: categoryPaginatedFetching, - } = useCategoryProducts(selectedCategory?.id?.toString() || "", { - enabled: !!selectedCategory && !isSubCategory, - page: currentPage, - limit: 6, - }); + const handlePriceChange = useCallback((values: number[]) => { + setPriceRange([values[0], values[1]]); + setCurrentPage(1); + setAllProducts([]); + }, []); - if (!slug) { - notFound(); - } + const handlePriceSortChange = useCallback( + (sortType: "none" | "lowToHigh" | "highToLow") => { + setPriceSort(sortType); + }, + [] + ); + + const resetFilters = useCallback(() => { + setSelectedBrands(new Set()); + setSelectedFilterCategories(new Set()); + setPriceRange([0, 10000]); + setPriceSort("none"); + setCurrentPage(1); + setAllProducts([]); + }, []); - // Helper function to find category by ID const findCategoryById = useCallback( (categories: Category[] | undefined, id: number): Category | null => { if (!categories) return null; - for (const category of categories) { if (category.id === id) return category; if (category.children) { @@ -163,177 +232,6 @@ export default function CategoryPageClient({ [] ); - // Helper to check if product already exists in list - const isProductInList = useCallback( - (list: Product[], newProduct: Product) => { - return list.some((product) => product.id === newProduct.id); - }, - [] - ); - - // Setup subcategories when category changes - useEffect(() => { - if (selectedCategory) { - setAllProducts([]); - setHasMore(true); - setCurrentPage(1); - - if (selectedCategory.children && selectedCategory.children.length > 0) { - setHasSubcategories(true); - setSubcategoriesToShow(selectedCategory.children); - } else { - setHasSubcategories(false); - setSubcategoriesToShow([]); - } - } - }, [selectedCategory?.id]); - - // Handle first page products for subcategories - useEffect(() => { - if ( - selectedCategory && - isSubCategory && - subcategoryProducts.length > 0 && - currentPage === 1 - ) { - setAllProducts(subcategoryProducts); - setHasMore(true); - } - }, [selectedCategory, subcategoryProducts, currentPage, isSubCategory]); - - // Handle paginated category products (non-subcategories) - useEffect(() => { - if (paginatedCategoryData && selectedCategory && !isSubCategory) { - if (paginatedCategoryData.data && paginatedCategoryData.data.length > 0) { - setAllProducts((prevProducts) => { - if (currentPage === 1) { - return [...paginatedCategoryData.data]; - } - - const newProducts = paginatedCategoryData.data.filter( - (newProduct: Product) => !isProductInList(prevProducts, newProduct) - ); - - return [...prevProducts, ...newProducts]; - }); - - setHasMore(!!paginatedCategoryData.pagination?.next_page_url); - } else if (currentPage === 1) { - setAllProducts([]); - setHasMore(false); - } - } - }, [ - paginatedCategoryData, - currentPage, - selectedCategory, - isSubCategory, - isProductInList, - ]); - - // Handle paginated subcategory products - useEffect(() => { - if ( - paginatedSubcategoryData && - selectedCategory && - isSubCategory && - currentPage > 1 - ) { - if ( - paginatedSubcategoryData.data && - paginatedSubcategoryData.data.length > 0 - ) { - setAllProducts((prevProducts) => { - const newProducts = paginatedSubcategoryData.data.filter( - (newProduct: Product) => !isProductInList(prevProducts, newProduct) - ); - - return [...prevProducts, ...newProducts]; - }); - - setHasMore(paginatedSubcategoryData.pagination?.hasMorePages || false); - } else { - setHasMore(false); - } - } - }, [ - paginatedSubcategoryData, - currentPage, - selectedCategory, - isSubCategory, - isProductInList, - ]); - - const loadMoreData = useCallback(() => { - if (!hasMore || categoryPaginatedFetching || subcategoryPaginatedLoading) { - return; - } - setCurrentPage((prevPage) => prevPage + 1); - }, [hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading]); - - const isLoading = - categoriesLoading || - (subcategoryLoading && currentPage === 1) || - (categoryPaginatedLoading && currentPage === 1); - - const products = useMemo(() => { - let productsList = [...allProducts]; - - if (priceSort === "lowToHigh") { - return [...productsList].sort( - (a, b) => - parseFloat(a.price_amount || "0") - parseFloat(b.price_amount || "0") - ); - } else if (priceSort === "highToLow") { - return [...productsList].sort( - (a, b) => - parseFloat(b.price_amount || "0") - parseFloat(a.price_amount || "0") - ); - } - - return productsList; - }, [priceSort, allProducts]); - - const totalItems = useMemo(() => { - if ( - paginatedCategoryData?.pagination && - !isSubCategory && - selectedCategory - ) { - return paginatedCategoryData.pagination.total || products.length || 0; - } - return products.length || 0; - }, [paginatedCategoryData, products, isSubCategory, selectedCategory]); - - const handlePriceSortChange = useCallback( - (sortType: "none" | "lowToHigh" | "highToLow") => { - setPriceSort(sortType); - }, - [] - ); - - const handleSubCategorySelect = useCallback( - (subCategory: Category) => { - setAllProducts([]); - setCurrentPage(1); - setHasMore(true); - setPriceSort("none"); - - router.push(`/${locale}/category/${subCategory.slug}`, { scroll: false }); - }, - [locale, router] - ); - - const handleCategoryClick = useCallback( - (category: Category) => { - setAllProducts([]); - setCurrentPage(1); - setHasMore(true); - router.push(`/${locale}/category/${category.slug}`); - }, - [locale, router] - ); - const renderBreadcrumbs = useCallback(() => { if (!categoriesData || !selectedCategory) return null; @@ -358,7 +256,9 @@ export default function CategoryPageClient({ {breadcrumbs.map((category, index) => (
); - }, [categoriesData, selectedCategory, findCategoryById, handleCategoryClick]); - - const pageTitle = selectedCategory?.name || t("category"); - - const handleFilterChange = useCallback((key: string, value: number) => { - setSelectedFilters((prev) => { - const newFilters = { ...prev }; - if (!newFilters[key]) { - newFilters[key] = new Set(); - } - - if (newFilters[key].has(value)) { - newFilters[key].delete(value); - } else { - newFilters[key].add(value); - } - - return newFilters; - }); - }, []); - - const handlePriceChange = useCallback((values: number[]) => { - setPriceRange([values[0], values[1]]); - }, []); - - const handlePriceInputChange = useCallback( - (type: "from" | "to", value: string) => { - const numValue = parseInt(value) || 0; - if (type === "from") { - setPriceRange((prev) => [numValue, prev[1]]); - } else { - setPriceRange((prev) => [prev[0], numValue]); - } - }, - [] - ); - - const resetFilters = useCallback(() => { - setSelectedFilters({ - brand: new Set(), - color: new Set(), - tag: new Set(), - }); - setPriceRange([0, 10000]); - setPriceSort("none"); - }, []); + }, [categoriesData, selectedCategory, findCategoryById, locale, router]); const FiltersContent = useCallback( () => (
- {hasSubcategories && subcategoriesToShow.length > 0 && ( + {filtersData?.categories && filtersData.categories.length > 0 && (
-

{t("subcategories")}

-
- {subcategoriesToShow.map((subCategory) => ( - + handleCategoryToggle(category.id)} + /> + {category.name} + + ))} +
+
+ )} + + {filtersData?.brands && filtersData.brands.length > 0 && ( +
+

{t("brands")}

+
+ {filtersData.brands.map((brand) => ( + ))}
@@ -479,13 +353,12 @@ export default function CategoryPageClient({ title={t("price")} priceRange={priceRange} onPriceChange={handlePriceChange} - onInputChange={handlePriceInputChange} translations={{ from: t("price_from"), to: t("price_to") }} />
), [ - hasSubcategories, - subcategoriesToShow, - slug, + filtersData, + selectedFilterCategories, + selectedBrands, priceSort, priceRange, t, - handleSubCategorySelect, + handleCategoryToggle, + handleBrandToggle, handlePriceSortChange, handlePriceChange, - handlePriceInputChange, resetFilters, ] ); - if (isLoading) return
{t("common.loading")}
; - - if (!selectedCategory && !categoriesLoading) { + if (categoriesLoading) return
{t("common.loading")}
; + if (!selectedCategory) return
{t("category_not_found")}
; - } + + const totalItems = + productsData?.pagination?.total || sortedProducts.length || 0; return (
- {selectedCategory && renderBreadcrumbs()} -

{pageTitle}

+ {renderBreadcrumbs()} +

{selectedCategory.name}

{t("total")}: {totalItems} {t("products")}

- {/* Desktop Filters - LEFT SIDE */}
- {/* Content - RIGHT SIDE */}
- {products.length > 0 ? ( + {sortedProducts.length > 0 ? (
- {products.map((product) => ( + {sortedProducts.map((product) => (
- {/* Mobile Filter Sheet */}
@@ -639,7 +510,12 @@ function PriceFilter({ id="price-to" type="number" value={priceRange[1]} - onChange={(e) => onInputChange("to", e.target.value)} + onChange={(e) => + onPriceChange([ + priceRange[0], + parseInt(e.target.value) || 10000, + ]) + } className="rounded-lg" />
diff --git a/features/category/hooks/useCategories.ts b/features/category/hooks/useCategories.ts index dc55307..26f1960 100644 --- a/features/category/hooks/useCategories.ts +++ b/features/category/hooks/useCategories.ts @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query" import { apiClient } from "@/lib/api" -import type { Category, Product, PaginatedResponse } from "@/lib/types/api" +import type { Category, Product, PaginatedResponse, FiltersResponse, ProductFilters } from "@/lib/types/api" // Get all categories as tree export function useCategories(options?: { enabled?: boolean }) { @@ -92,6 +92,70 @@ export function useAllCategoryProducts( }) } +export function useCategoryFilters( + categoryId: number | string | undefined, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["category-filters", categoryId], + queryFn: async () => { + const response = await apiClient.get( + "/filters", + { + params: { category_id: categoryId }, + } + ) + return response.data.data + }, + enabled: options?.enabled !== false && !!categoryId, + staleTime: 1000 * 60 * 15, + }) +} + +// Get filtered category products +export function useFilteredCategoryProducts( + categoryId: number | string, + filters: ProductFilters, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["category", categoryId, "filtered-products", filters], + queryFn: async () => { + const params: Record = { + page: filters.page || 1, + limit: filters.limit || 6, + } + + if (filters.brands && filters.brands.length > 0) { + params.brands = filters.brands.join(',') + } + + if (filters.categories && filters.categories.length > 0) { + params.categories = filters.categories.join(',') + } + + if (filters.min_price !== undefined) { + params.min_price = filters.min_price + } + + if (filters.max_price !== undefined) { + params.max_price = filters.max_price + } + + const response = await apiClient.get>( + `/categories/${categoryId}/products`, + { params } + ) + + return { + data: response.data.data || [], + pagination: response.data.pagination || {} + } + }, + enabled: options?.enabled !== false && !!categoryId, + }) +} + // Get products from category and children WITH pagination (mimics RTK getAllCategoryProductsPaginated) export function useAllCategoryProductsPaginated( category: Category | undefined, diff --git a/features/home/components/HomePage.tsx b/features/home/components/HomePage.tsx index 8ecf158..cbde7be 100644 --- a/features/home/components/HomePage.tsx +++ b/features/home/components/HomePage.tsx @@ -49,7 +49,7 @@ export default function HomePage() { const hasMore = collections ? visibleCount < collections.length : false; return ( -
+
{!carouselsLoading && carouselItems.length > 0 && ( )} diff --git a/features/home/components/ProductCard.tsx b/features/home/components/ProductCard.tsx new file mode 100644 index 0000000..762289e --- /dev/null +++ b/features/home/components/ProductCard.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, MouseEvent, useEffect, useRef } from "react"; +import { Heart } from "lucide-react"; +import { toast } from "sonner"; +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, +} from "@/components/ui/carousel"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +type ProductCardProps = { + id: number; + name: string; + price: number | null; + struct_price_text: string; + discount?: number | null; + discount_text?: string | null; + images: string[]; + is_favorite: boolean; + labels?: { text: string; bg_color: string }[]; + price_color?: string; + height?: number; + width?: number; + button?: boolean; +}; + +export default function ProductCard({ + id, + name, + price, + struct_price_text, + discount, + discount_text, + images, + is_favorite, + labels = [], + price_color = "#005bff", + height = 360, + width = 280, + button = true, +}: ProductCardProps) { + const [favorite, setFavorite] = useState(is_favorite); + const [api, setApi] = useState(); + const [current, setCurrent] = useState(0); + const autoplayRef = useRef(null); + + const hasMultipleImages = images.length > 1; + + // Track carousel current slide + useEffect(() => { + if (!api) return; + + setCurrent(api.selectedScrollSnap()); + + api.on("select", () => { + setCurrent(api.selectedScrollSnap()); + }); + }, [api]); + + // Auto-play functionality - 3 seconds + useEffect(() => { + if (!api || !hasMultipleImages) return; + + const startAutoplay = () => { + autoplayRef.current = setInterval(() => { + if (api.canScrollNext()) { + api.scrollNext(); + } else { + api.scrollTo(0); + } + }, 3000); + }; + + const stopAutoplay = () => { + if (autoplayRef.current) { + clearInterval(autoplayRef.current); + autoplayRef.current = null; + } + }; + + startAutoplay(); + + return () => stopAutoplay(); + }, [api, hasMultipleImages]); + + const handleFavorite = async (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const newFavoriteState = !favorite; + setFavorite(newFavoriteState); + + if (newFavoriteState) { + toast.success("Товар добавлен в избранное"); + } else { + toast.success("Товар удален из избранного"); + } + }; + + const handleCardClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if ( + target.closest('button') || + target.closest('[data-carousel-control="true"]') + ) { + e.preventDefault(); + } + }; + + const handleNavClick = (e: MouseEvent, action: () => void) => { + e.preventDefault(); + e.stopPropagation(); + action(); + }; + + return ( + + + {/* Image Section with Carousel */} +
+ + + {images.map((image, index) => ( + +
+ {`${name} +
+
+ ))} +
+ + {/* Navigation Arrows - Only show if multiple images */} + {hasMultipleImages && ( + <> + handleNavClick(e, () => api?.scrollPrev())} + /> + handleNavClick(e, () => api?.scrollNext())} + /> + + )} +
+ + {/* Favorite Button */} + + + {/* Image Indicators */} + {hasMultipleImages && ( +
+ {images.map((_, index) => ( +
+ )} + + {/* Labels */} + {labels?.length > 0 && ( +
+ {labels.map((label, idx) => ( + + {label.text} + + ))} +
+ )} +
+ + {/* Content */} + +

+ {struct_price_text} +

+

{name}

+
+
+
+ ); +} \ No newline at end of file diff --git a/features/home/components/ProductGrid.tsx b/features/home/components/ProductGrid.tsx index cb65f1a..448996a 100644 --- a/features/home/components/ProductGrid.tsx +++ b/features/home/components/ProductGrid.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { ChevronRight } from "lucide-react"; -import ProductCard from "@/components/ProductCard"; +import ProductCard from "@/features/home/components/ProductCard"; import { Skeleton } from "@/components/ui/skeleton"; import { useCollectionProducts } from "@/lib/hooks"; import type { Collection } from "@/lib/types/api"; @@ -22,7 +22,6 @@ export default function CollectionSection({ collection, locale }: Props) { isError, } = useCollectionProducts(collection.id, { enabled: shouldRender }); - // Determine if section should render based on products useEffect(() => { if (!isLoading && productsData) { const hasProducts = productsData.data && productsData.data.length > 0; @@ -30,7 +29,6 @@ export default function CollectionSection({ collection, locale }: Props) { } }, [isLoading, productsData]); - // Don't render if no products after loading if (!isLoading && (!productsData?.data || productsData.data.length === 0)) { return null; } @@ -39,7 +37,6 @@ export default function CollectionSection({ collection, locale }: Props) { router.push(`/${locale}/collections/${collection.id}`); }; - // Show skeleton while loading if (isLoading) { return (
@@ -56,16 +53,14 @@ export default function CollectionSection({ collection, locale }: Props) { ); } - // Show error state if (isError) { - return null; // Silently skip errored collections + return null; } - // Slice to show only first 4 products - const displayProducts = productsData?.data.slice(0, 4) || []; + const displayProducts = productsData?.data.slice(0, 10) || []; return ( -
+
{displayProducts.map((product) => { - // Extract first media image or use placeholder - const firstImage = - product.media?.[0]?.images_800x800 || - product.media?.[0]?.images_720x720 || - product.media?.[0]?.thumbnail || - "/placeholder-product.jpg"; + // 🔥 TÜM RESİMLERİ AL - Burada değişiklik! + const allImages = product.media?.map( + (media) => + media.images_800x800 || + media.images_720x720 || + media.images_400x400 || + media.thumbnail + ).filter(Boolean) || ["/placeholder-product.jpg"]; - // Format price const formattedPrice = product.price_amount ? `${parseFloat(product.price_amount).toFixed(2)} TMT` : "Price not available"; @@ -99,7 +95,7 @@ export default function CollectionSection({ collection, locale }: Props) { product.price_amount ? parseFloat(product.price_amount) : null } struct_price_text={formattedPrice} - images={[firstImage]} + images={allImages} // 🔥 Array olarak tüm resimler is_favorite={false} labels={[]} price_color="#111" @@ -112,4 +108,4 @@ export default function CollectionSection({ collection, locale }: Props) {
); -} +} \ No newline at end of file diff --git a/features/openStore/hooks/useOpenStore.ts b/features/openStore/hooks/useOpenStore.ts new file mode 100644 index 0000000..1334fbf --- /dev/null +++ b/features/openStore/hooks/useOpenStore.ts @@ -0,0 +1,47 @@ +"use client" + +import { useMutation } from "@tanstack/react-query" +import { apiClient } from "@/lib/api" + +interface OpenStoreData { + firstName: string + lastName: string + email: string + phone: string + patentFile: File +} + +interface OpenStoreResponse { + success: boolean + message: string + data?: any +} + +const API_ENDPOINTS = { + openStore: "forms/newsletter-subscription", +} + +export function useOpenStore() { + return useMutation({ + mutationFn: async (data: OpenStoreData) => { + const formData = new FormData() + formData.append("firstname", data.firstName) + formData.append("lastname", data.lastName) + formData.append("email", data.email) + formData.append("phone", data.phone) + formData.append("file", data.patentFile) + + const response = await apiClient.post( + API_ENDPOINTS.openStore, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ) + + return response.data + }, + }) +} \ No newline at end of file diff --git a/features/orders/components/orders-page-client.tsx b/features/orders/components/OrderPage.tsx similarity index 100% rename from features/orders/components/orders-page-client.tsx rename to features/orders/components/OrderPage.tsx diff --git a/features/products/components/ProductPageContent.tsx b/features/products/components/ProductPageContent.tsx index a9b62be..e95e95e 100644 --- a/features/products/components/ProductPageContent.tsx +++ b/features/products/components/ProductPageContent.tsx @@ -17,7 +17,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useProductsBySlug } from "@/features/products/hooks/useProducts"; -import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/features/cart/hooks/useCart"; +import { useAddToCart, useUpdateCartItemQuantity, useRemoveFromCart, useCart } from "@/features/cart/hooks/useCart"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -50,11 +50,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { const retryTimerRef = useRef(undefined); const syncToServerRef = useRef<((quantity: number) => void) | null>(null); const retrySyncRef = useRef<((quantity: number) => void) | null>(null); + const autoplayTimerRef = useRef(undefined); const { data: product, isLoading: productLoading, error } = useProductsBySlug(slug); const { data: cartData, refetch: refetchCart } = useCart(); const addToCartMutation = useAddToCart(); const updateCartMutation = useUpdateCartItemQuantity(); + const removeFromCartMutation = useRemoveFromCart(); const cartItem = useMemo(() => cartData?.data?.find((item: any) => item.product?.id === product?.id), @@ -64,6 +66,46 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { const availableStock = product?.stock || 0; + const imageUrls = useMemo(() => + product?.media?.map(m => m.images_800x800 || m.images_720x720 || m.thumbnail) || [], + [product] + ); + + // Auto-play carousel every 3 seconds + useEffect(() => { + if (imageUrls.length <= 1) return; + + const startAutoplay = () => { + autoplayTimerRef.current = setInterval(() => { + setSelectedImage(prev => (prev + 1) % imageUrls.length); + }, 3000); + }; + + startAutoplay(); + + return () => { + if (autoplayTimerRef.current) { + clearInterval(autoplayTimerRef.current); + } + }; + }, [imageUrls.length]); + + // Reset autoplay timer when user manually selects image + const handleImageSelect = useCallback((index: number) => { + setSelectedImage(index); + + // Reset autoplay timer + if (autoplayTimerRef.current) { + clearInterval(autoplayTimerRef.current); + } + + if (imageUrls.length > 1) { + autoplayTimerRef.current = setInterval(() => { + setSelectedImage(prev => (prev + 1) % imageUrls.length); + }, 3000); + } + }, [imageUrls.length]); + useEffect(() => { if (cartItem?.product_quantity) { setLocalQuantity(cartItem.product_quantity); @@ -145,7 +187,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setSyncError(false); try { - if (isInCart) { + // If quantity is 0, remove from cart + if (quantity === 0) { + await removeFromCartMutation.mutateAsync(product.id); + toast.success(t("removed_from_cart")); + } else if (isInCart) { await updateCartMutation.mutateAsync({ productId: product.id, quantity: quantity, @@ -162,7 +208,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { retryCountRef.current = 0; clearPendingUpdate(); - // Refetch cart to update UI state immediately await refetchCart(); if (pendingQuantityRef.current !== null) { @@ -181,7 +226,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { retrySyncRef.current?.(quantity); } - }, [product?.id, isInCart, updateCartMutation, addToCartMutation, cartItem, clearPendingUpdate, refetchCart]); + }, [product?.id, isInCart, updateCartMutation, addToCartMutation, removeFromCartMutation, cartItem, clearPendingUpdate, refetchCart, t]); syncToServerRef.current = syncToServer; @@ -239,13 +284,13 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current); if (retryTimerRef.current) clearTimeout(retryTimerRef.current); + if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current); }; }, []); const handleAddToCart = useCallback(async () => { if (!product?.id) return; - // Set syncing state immediately for UI feedback setIsSyncing(true); try { @@ -254,7 +299,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { quantity: localQuantity, }); - // Refetch cart immediately to update isInCart state await refetchCart(); setIsSyncing(false); @@ -281,7 +325,8 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { }, [localQuantity, availableStock]); const handleQuantityDecrease = useCallback(() => { - if (localQuantity <= 1) return; + // Allow decreasing to 0 to remove from cart + if (localQuantity <= 0) return; setLocalQuantity(prev => prev - 1); }, [localQuantity]); @@ -290,11 +335,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setIsFavorite(!isFavorite); }, [isFavorite]); - const imageUrls = useMemo(() => - product?.media?.map(m => m.images_800x800 || m.images_720x720 || m.thumbnail) || [], - [product] - ); - const loadingSkeleton = useMemo(() => (
@@ -354,7 +394,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { {imageUrls.map((image, index) => (