diff --git a/app/[locale]/cart/page.tsx b/app/[locale]/cart/page.tsx index 950b6a0..d8f6d67 100644 --- a/app/[locale]/cart/page.tsx +++ b/app/[locale]/cart/page.tsx @@ -25,6 +25,7 @@ export default function CartPage() { const [note, setNote] = useState(""); const [phone, setPhone] = useState(""); const [name, setName] = useState(""); + const [lastName, setLastName] = useState(""); const router = useRouter(); const t = useTranslations(); @@ -42,6 +43,7 @@ export default function CartPage() { const orderData = userStore.getOrderData(); if (orderData) { if (orderData.customer_name) setName(orderData.customer_name); + if (orderData.customer_last_name) setLastName(orderData.customer_last_name); if (orderData.customer_phone) setPhone(orderData.customer_phone); } }, []); @@ -227,8 +229,10 @@ export default function CartPage() { paymentTypes={paymentTypes} phone={phone} name={name} + lastName={lastName} onPhoneChange={setPhone} onNameChange={setName} + onLastNameChange={setLastName} onPaymentTypeChange={setPaymentType} onDeliveryTypeChange={handleDeliveryTypeChange} onRegionChange={setSelectedRegion} diff --git a/app/[locale]/collections/[slug]/page.tsx b/app/[locale]/collections/[slug]/page.tsx new file mode 100644 index 0000000..f784543 --- /dev/null +++ b/app/[locale]/collections/[slug]/page.tsx @@ -0,0 +1,38 @@ +import type { Metadata } from "next" + +type Props = { + params: Promise<{ locale: string; slug: string }> +} + +export const revalidate = 600 // ISR: Revalidate every 10 minutes + +export async function generateMetadata({ params }: Props): Promise { + const { locale, slug } = await params + + return { + title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, + description: `Browse ${slug} collection products in our store`, + openGraph: { + locale, + type: "website", + title: `${slug.charAt(0).toUpperCase() + slug.slice(1)} | E-Commerce`, + description: `Browse ${slug} collection products in our store`, + }, + } +} + +export async function generateStaticParams() { + // Generate static params for popular collections + const collections = ["new-arrivals", "best-sellers", "featured"] + return collections.map((slug) => ({ slug })) +} + +export default async function CollectionPage(props: Props) { + const params = await props.params + + const CollectionPageClient = ( + await import("../../../../features/collections/components/CollectionPageClient") + ).default + + return +} \ No newline at end of file diff --git a/app/[locale]/orders/page.tsx b/app/[locale]/orders/page.tsx index 0ee5449..eb5a770 100644 --- a/app/[locale]/orders/page.tsx +++ b/app/[locale]/orders/page.tsx @@ -13,23 +13,22 @@ const metadataContent = { } as const; interface PageProps { - params: { + params: Promise<{ locale: string; - }; + }>; } export async function generateMetadata( { params }: PageProps, parent: ResolvingMetadata ): Promise { - const locale = params.locale as keyof typeof metadataContent; - - const content = metadataContent[locale] || metadataContent.ru; + const { locale } = await params; + const localeKey = locale as keyof typeof metadataContent; + const content = metadataContent[localeKey] || metadataContent.ru; return { title: content.title, description: content.description, - robots: { index: false, follow: false, @@ -39,5 +38,6 @@ export async function generateMetadata( } export default async function OrdersPage({ params }: PageProps) { - return ; -} + const { locale } = await params; + return ; +} \ No newline at end of file diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index b7b88a3..68c4a88 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -81,7 +81,7 @@ export default function Header({ locale = "ru" }: HeaderProps) { {/* Favorites Button */} - - - + {/* Orders Button */} - - - + {/* Cart Button */} - - - - - {isAuthenticated ? ( - - - - ) : ( - - )} + {cartData?.data?.length || 0} + + + {t("common.cart")} + + + {/* Profile/Login Button */} + @@ -177,7 +177,7 @@ export default function MobileBottomNav({ - {t.catalog} + {t("common.catalog")}
@@ -214,4 +214,4 @@ export default function MobileBottomNav({ ); -} +} \ No newline at end of file diff --git a/components/layout/ui/LanguageSelector.tsx b/components/layout/ui/LanguageSelector.tsx index 79e6cf9..b55108e 100644 --- a/components/layout/ui/LanguageSelector.tsx +++ b/components/layout/ui/LanguageSelector.tsx @@ -43,7 +43,7 @@ export default function LanguageSelector() { return ( onLastNameChange(e.target.value)} + placeholder={t("last_name")} + className="rounded-xl" + /> +
); -} \ No newline at end of file +} diff --git a/features/category/components/CategoryProductsGrid.tsx b/features/category/components/CategoryProductsGrid.tsx index d13f051..1db198c 100644 --- a/features/category/components/CategoryProductsGrid.tsx +++ b/features/category/components/CategoryProductsGrid.tsx @@ -6,6 +6,7 @@ interface CategoryProductsGridProps { products: Product[]; hasMore: boolean; onLoadMore: () => void; + isFetching?: boolean; // Yeni prop - loading durumu için translations: { loading: string; no_results: string; @@ -16,9 +17,10 @@ export default function CategoryProductsGrid({ products, hasMore, onLoadMore, + isFetching = false, translations, }: CategoryProductsGridProps) { - if (products.length === 0) { + if (products.length === 0 && !isFetching) { return (
{translations.no_results} @@ -35,9 +37,19 @@ export default function CategoryProductsGrid({ style={{ overflow: "visible" }} loader={
-
{translations.loading}
+
+
+ {translations.loading} +
} + endMessage={ + products.length > 0 && !hasMore ? ( +
+ {/* Opsiyonel: "Tüm ürünler yüklendi" mesajı */} +
+ ) : null + } >
{products.map((product) => ( @@ -55,6 +67,19 @@ export default function CategoryProductsGrid({ /> ))}
+ + {/* İlk yükleme için skeleton göster */} + {isFetching && products.length === 0 && ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ )} ); } \ No newline at end of file diff --git a/features/collections/components/CollectionFilters.tsx b/features/collections/components/CollectionFilters.tsx new file mode 100644 index 0000000..c24ad51 --- /dev/null +++ b/features/collections/components/CollectionFilters.tsx @@ -0,0 +1,233 @@ +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import type { FilterBrand, FilterCategory } from "@/lib/types/api"; + +interface FiltersData { + categories: FilterCategory[]; + brands: FilterBrand[]; +} + +interface CollectionFiltersProps { + filtersData: FiltersData | undefined; + selectedBrands: Set; + selectedCategories: Set; + priceSort: "none" | "lowToHigh" | "highToLow"; + priceRange: [number, number]; + onBrandToggle: (brandId: number) => void; + onCategoryToggle: (categoryId: number) => void; + onPriceSortChange: (sortType: "none" | "lowToHigh" | "highToLow") => void; + onPriceChange: (values: number[]) => void; + onReset: () => void; + translations: { + category: string; + brands: string; + sort: string; + default: string; + price_low_to_high: string; + price_high_to_low: string; + price: string; + price_from: string; + price_to: string; + reset: string; + }; +} + +export default function CollectionFilters({ + filtersData, + selectedBrands, + selectedCategories, + priceSort, + priceRange, + onBrandToggle, + onCategoryToggle, + onPriceSortChange, + onPriceChange, + onReset, + translations, +}: CollectionFiltersProps) { + return ( +
+ {filtersData?.categories && filtersData.categories.length > 0 && ( + + {filtersData.categories.map((category) => ( + onCategoryToggle(category.id)} + label={category.name} + /> + ))} + + )} + + {filtersData?.brands && filtersData.brands.length > 0 && ( + + {filtersData.brands.map((brand) => ( + onBrandToggle(brand.id)} + label={brand.name} + /> + ))} + + )} + + + onPriceSortChange("none")} + label={translations.default} + /> + onPriceSortChange("lowToHigh")} + label={translations.price_low_to_high} + /> + onPriceSortChange("highToLow")} + label={translations.price_high_to_low} + /> + + + + + +
+ ); +} + +function FilterSection({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function CheckboxItem({ + checked, + onCheckedChange, + label, +}: { + checked: boolean; + onCheckedChange: () => void; + label: string; +}) { + return ( + + ); +} + +function RadioItem({ + name, + checked, + onChange, + label, +}: { + name: string; + checked: boolean; + onChange: () => void; + label: string; +}) { + return ( + + ); +} + +function PriceFilter({ + title, + priceRange, + onPriceChange, + translations, +}: { + title: string; + priceRange: [number, number]; + onPriceChange: (values: number[]) => void; + translations: { from: string; to: string }; +}) { + return ( +
+

{title}

+
+
+
+ + + onPriceChange([parseInt(e.target.value) || 0, priceRange[1]]) + } + className="rounded-lg" + /> +
+
+ + + onPriceChange([ + priceRange[0], + parseInt(e.target.value) || 10000, + ]) + } + className="rounded-lg" + /> +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/features/collections/components/CollectionFiltersSheet.tsx b/features/collections/components/CollectionFiltersSheet.tsx new file mode 100644 index 0000000..f81c282 --- /dev/null +++ b/features/collections/components/CollectionFiltersSheet.tsx @@ -0,0 +1,55 @@ +import { SlidersHorizontal, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +interface CollectionFiltersSheetProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + filterLabel: string; + closeLabel: string; + children: React.ReactNode; +} + +export default function CollectionFiltersSheet({ + isOpen, + onOpenChange, + filterLabel, + closeLabel, + children, +}: CollectionFiltersSheetProps) { + return ( + + + + + + + {filterLabel} + + + + {children} + + + + ); +} \ No newline at end of file diff --git a/features/collections/components/CollectionPageClient.tsx b/features/collections/components/CollectionPageClient.tsx new file mode 100644 index 0000000..96fbc67 --- /dev/null +++ b/features/collections/components/CollectionPageClient.tsx @@ -0,0 +1,261 @@ +"use client"; + +import { useEffect, useState, useMemo, useCallback } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useTranslations } from "next-intl"; +import type { Product } from "@/lib/types/api"; +import CollectionFilters from "./CollectionFilters"; +import CollectionProductsGrid from "./CollectionProductsGrid"; +import CollectionFiltersSheet from "./CollectionFiltersSheet"; +import { + useCollections, + useCollectionFilters, + useFilteredCollectionProducts, +} from "@/features/collections/hooks/useCollections"; + +interface CollectionPageClientProps { + params: { locale: string; slug: string }; +} + +export default function CollectionPageClient({ + params, +}: CollectionPageClientProps) { + const { slug } = params; + const t = useTranslations(); + const [isSheetOpen, setIsSheetOpen] = useState(false); + + const { data: collectionsData, isLoading: collectionsLoading } = + useCollections(); + + const selectedCollection = useMemo(() => { + if (!collectionsData || !slug) return null; + return collectionsData.find((col) => col.slug === slug); + }, [collectionsData, slug]); + + // State management + const [currentPage, setCurrentPage] = useState(1); + const [allProducts, setAllProducts] = useState([]); + const [priceSort, setPriceSort] = useState<"none" | "lowToHigh" | "highToLow">("none"); + const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]); + const [selectedBrands, setSelectedBrands] = useState>(new Set()); + const [selectedCategories, setSelectedCategories] = useState>(new Set()); + + // Fetch filters + const { data: filtersData } = useCollectionFilters(selectedCollection?.id, { + enabled: !!selectedCollection, + }); + + // Build filter params + const filterParams = useMemo(() => { + const params: any = { + page: currentPage, + limit: 6, + }; + + if (selectedBrands.size > 0) { + params.brands = Array.from(selectedBrands); + } + + if (selectedCategories.size > 0) { + params.categories = Array.from(selectedCategories); + } + + if (priceRange[0] > 0) { + params.min_price = priceRange[0]; + } + + if (priceRange[1] < 10000) { + params.max_price = priceRange[1]; + } + + return params; + }, [currentPage, selectedBrands, selectedCategories, priceRange]); + + // Fetch filtered products + const { data: productsData, isFetching } = useFilteredCollectionProducts( + selectedCollection?.id?.toString() || "", + filterParams, + { enabled: !!selectedCollection } + ); + + // Reset on collection change + useEffect(() => { + if (selectedCollection) { + setAllProducts([]); + setCurrentPage(1); + setSelectedBrands(new Set()); + setSelectedCategories(new Set()); + setPriceRange([0, 10000]); + setPriceSort("none"); + } + }, [selectedCollection?.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]); + + // Filter handlers + const handleBrandToggle = useCallback((brandId: number) => { + setSelectedBrands((prev) => { + const newSet = new Set(prev); + newSet.has(brandId) ? newSet.delete(brandId) : newSet.add(brandId); + return newSet; + }); + setCurrentPage(1); + setAllProducts([]); + }, []); + + const handleCategoryToggle = useCallback((categoryId: number) => { + setSelectedCategories((prev) => { + const newSet = new Set(prev); + newSet.has(categoryId) ? newSet.delete(categoryId) : newSet.add(categoryId); + return newSet; + }); + setCurrentPage(1); + setAllProducts([]); + }, []); + + const handlePriceChange = useCallback((values: number[]) => { + setPriceRange([values[0], values[1]]); + setCurrentPage(1); + setAllProducts([]); + }, []); + + const handlePriceSortChange = useCallback( + (sortType: "none" | "lowToHigh" | "highToLow") => { + setPriceSort(sortType); + }, + [] + ); + + const resetFilters = useCallback(() => { + setSelectedBrands(new Set()); + setSelectedCategories(new Set()); + setPriceRange([0, 10000]); + setPriceSort("none"); + setCurrentPage(1); + setAllProducts([]); + }, []); + + const filterTranslations = useMemo( + () => ({ + category: t("category"), + brands: t("brands"), + sort: t("sort"), + default: t("default"), + price_low_to_high: t("price_low_to_high"), + price_high_to_low: t("price_high_to_low"), + price: t("price"), + price_from: t("price_from"), + price_to: t("price_to"), + reset: t("reset"), + }), + [t] + ); + + if (collectionsLoading) return
{t("common.loading")}
; + if (!selectedCollection) + return
{t("collection_not_found")}
; + + return ( +
+

+ {selectedCollection.name} +

+ +
+ {/* Desktop Filters Sidebar */} +
+ + + +
+ + {/* Products Grid */} +
+ +
+
+ + {/* Mobile Filters Sheet */} + + + +
+ ); +} \ No newline at end of file diff --git a/features/collections/components/CollectionProductsGrid.tsx b/features/collections/components/CollectionProductsGrid.tsx new file mode 100644 index 0000000..f75695c --- /dev/null +++ b/features/collections/components/CollectionProductsGrid.tsx @@ -0,0 +1,60 @@ +import InfiniteScroll from "react-infinite-scroll-component"; +import ProductCard from "@/features/home/components/ProductCard"; +import type { Product } from "@/lib/types/api"; + +interface CollectionProductsGridProps { + products: Product[]; + hasMore: boolean; + onLoadMore: () => void; + translations: { + loading: string; + no_results: string; + }; +} + +export default function CollectionProductsGrid({ + products, + hasMore, + onLoadMore, + translations, +}: CollectionProductsGridProps) { + if (products.length === 0) { + return ( +
+ {translations.no_results} +
+ ); + } + + return ( + +
{translations.loading}
+
+ } + > +
+ {products.map((product) => ( + + ))} +
+ + ); +} \ No newline at end of file diff --git a/features/collections/hooks/useCollections.ts b/features/collections/hooks/useCollections.ts new file mode 100644 index 0000000..5d2ad92 --- /dev/null +++ b/features/collections/hooks/useCollections.ts @@ -0,0 +1,161 @@ +import { useQuery } from "@tanstack/react-query"; +import { apiClient } from "@/lib/api"; +import type { + Collection, + Product, + PaginatedResponse, + FiltersResponse, + ProductFilters, +} from "@/lib/types/api"; + +// Get all collections +export function useCollections(options?: { enabled?: boolean }) { + return useQuery({ + queryKey: ["collections"], + queryFn: async () => { + const response = await apiClient.get>( + "/collections" + ); + return response.data.data || response.data; + }, + enabled: options?.enabled !== false, + staleTime: 1000 * 60 * 30, // 30 minutes + }); +} + +// Get single collection by ID +export function useCollection( + id: number | string, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["collection", id], + queryFn: async () => { + const response = await apiClient.get(`/collections/${id}`); + return response.data; + }, + enabled: options?.enabled !== false && !!id, + staleTime: 1000 * 60 * 15, + }); +} + +// Get products for a collection with pagination +export function useCollectionProducts( + collectionId: number | string, + options?: { + enabled?: boolean; + page?: number; + limit?: number; + } +) { + return useQuery({ + queryKey: [ + "collection", + collectionId, + "products", + options?.page, + options?.limit, + ], + queryFn: async () => { + const response = await apiClient.get>( + `/collections/${collectionId}/products`, + { + params: { + page: options?.page || 1, + per_page: options?.limit, + }, + } + ); + return { + data: response.data.data || [], + pagination: response.data.pagination || {}, + }; + }, + enabled: options?.enabled !== false && !!collectionId, + }); +} + +// Get filters for collection products +export function useCollectionFilters( + collectionId: number | string | undefined, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["collection-filters", collectionId], + queryFn: async () => { + const response = await apiClient.get("/filters", { + params: { collection_id: collectionId }, + }); + return response.data.data; + }, + enabled: options?.enabled !== false && !!collectionId, + staleTime: 1000 * 60 * 15, + }); +} + +// Get filtered collection products +export function useFilteredCollectionProducts( + collectionId: number | string, + filters: ProductFilters, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["collection", collectionId, "filtered-products", filters], + queryFn: async () => { + const params: Record = { + page: filters.page || 1, + per_page: 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>( + `/collections/${collectionId}/products`, + { params } + ); + + return { + data: response.data.data || [], + pagination: response.data.pagination || {}, + }; + }, + enabled: options?.enabled !== false && !!collectionId, + }); +} + +// Check if collection has products +export function useCheckCollectionHasProducts( + collectionId: number | string, + options?: { enabled?: boolean } +) { + return useQuery({ + queryKey: ["collection", collectionId, "has-products"], + queryFn: async () => { + const response = await apiClient.get>( + `/collections/${collectionId}/products`, + { + params: { limit: 1 }, + } + ); + return { + hasProducts: response.data.data && response.data.data.length > 0, + }; + }, + enabled: options?.enabled !== false && !!collectionId, + staleTime: 1000 * 60 * 5, + }); +} \ No newline at end of file diff --git a/features/home/components/ProductGrid.tsx b/features/home/components/ProductGrid.tsx index 7a18f07..3cb9763 100644 --- a/features/home/components/ProductGrid.tsx +++ b/features/home/components/ProductGrid.tsx @@ -19,7 +19,7 @@ export default function CollectionSection({ collection, locale }: Props) { } = useCollectionProducts(collection.id); const handleTitleClick = () => { - router.push(`/${locale}/collections/${collection.id}`); + router.push(`/collections/${collection.slug}`); }; // Hide section if no products diff --git a/features/orders/components/OrderPage.tsx b/features/orders/components/OrderPage.tsx index 825eafa..4b45a19 100644 --- a/features/orders/components/OrderPage.tsx +++ b/features/orders/components/OrderPage.tsx @@ -195,8 +195,8 @@ export default function OrdersPageClient({ locale }: OrdersPageClientProps) {

{t("my_orders")}

- - + + {t("active_orders")} ({activeOrders.length}) diff --git a/features/products/components/ProductImageGallery.tsx b/features/products/components/ProductImageGallery.tsx new file mode 100644 index 0000000..cc6f811 --- /dev/null +++ b/features/products/components/ProductImageGallery.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import Image from "next/image"; + +interface ProductImageGalleryProps { + images: string[]; + productName: string; + noImageText: string; +} + +export function ProductImageGallery({ + images, + productName, + noImageText, +}: ProductImageGalleryProps) { + const [selectedImage, setSelectedImage] = useState(0); + const autoplayTimerRef = useRef(undefined); + + useEffect(() => { + if (images.length <= 1) return; + + const startAutoplay = () => { + autoplayTimerRef.current = setInterval(() => { + setSelectedImage((prev) => (prev + 1) % images.length); + }, 3000); + }; + + startAutoplay(); + return () => { + if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current); + }; + }, [images.length]); + + const handleImageSelect = useCallback( + (index: number) => { + setSelectedImage(index); + if (autoplayTimerRef.current) clearInterval(autoplayTimerRef.current); + if (images.length > 1) { + autoplayTimerRef.current = setInterval(() => { + setSelectedImage((prev) => (prev + 1) % images.length); + }, 3000); + } + }, + [images.length] + ); + + return ( +
+
+
+ {images.length > 0 ? ( + {productName} + ) : ( +
+ {noImageText} +
+ )} +
+ + {images.length > 1 && ( +
+ {images.map((image, index) => ( + + ))} +
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/features/products/components/ProductInfoCard.tsx b/features/products/components/ProductInfoCard.tsx new file mode 100644 index 0000000..1bc79ee --- /dev/null +++ b/features/products/components/ProductInfoCard.tsx @@ -0,0 +1,141 @@ +import { Card } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; +import { Star } from "lucide-react"; + +interface ProductProperty { + name: string; + value: string; +} + +interface ProductInfoCardProps { + brandName?: string; + stock?: number; + barcode?: string; + colour?: string; + properties?: ProductProperty[]; + description?: string; + averageRating: number; + reviewsCount: number; + t: (key: string, params?: any) => string; +} + +export function ProductInfoCard({ + brandName, + stock, + barcode, + colour, + properties, + description, + averageRating, + reviewsCount, + t, +}: ProductInfoCardProps) { + const renderStars = (rating: number) => { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + }; + + return ( +
+ +

{t("about_product")}

+
+ {brandName && ( + <> +
+ {t("brands")} + {brandName} +
+ + + )} + + {stock !== undefined && ( + <> +
+ {t("stock")} + + {stock === 0 + ? t("out_of_stock") + : stock <= 5 + ? `${t("only_left", { count: stock })}` + : stock} + +
+ + + )} + + {barcode && ( + <> +
+ {t("barcode")} + {barcode} +
+ + + )} + + {colour && ( + <> +
+ {t("color")} + {colour} +
+ + + )} + + {properties && properties.length > 0 && ( + <> + {properties.map( + (prop, idx) => + prop.value && ( +
+
+ {prop.name} + {prop.value} +
+ {idx < properties.length - 1 && } +
+ ) + )} + + )} +
+
+ + {description && ( + +

+ {t("product_description")} +

+
+ + )} +
+ ); +} \ No newline at end of file diff --git a/features/products/components/ProductPageContent.tsx b/features/products/components/ProductPageContent.tsx index 4a18de2..31b4d21 100644 --- a/features/products/components/ProductPageContent.tsx +++ b/features/products/components/ProductPageContent.tsx @@ -1,30 +1,12 @@ "use client"; import { useState, useCallback, useMemo, useRef, useEffect } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { - Minus, - Plus, - Heart, - ShoppingCart, - Store, - Loader2, - AlertTriangle, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Skeleton } from "@/components/ui/skeleton"; import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { useProductsBySlug } from "@/features/products/hooks/useProducts"; + useProductsBySlug, + useRelatedProducts, + useSubmitReview, +} from "@/features/products/hooks/useProducts"; import { useAddToCart, useUpdateCartItemQuantity, @@ -33,6 +15,13 @@ import { } from "@/features/cart/hooks/useCart"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; +import { ProductImageGallery } from "./ProductImageGallery"; +import { ProductInfoCard } from "./ProductInfoCard"; +import { ProductPurchaseCard } from "./ProductPurchaseCard"; +import { ProductReviewsSection } from "./ProductReviewsSection"; +import { RelatedProductsSection } from "./RelatedProductsSection"; +import { ReviewModal } from "./ReviewModal"; +import { StockLimitModal } from "./StockLimitModal"; interface ProductDetailProps { slug: string; @@ -47,12 +36,12 @@ interface PendingUpdate { } export default function ProductPageContent({ slug }: ProductDetailProps) { - const [selectedImage, setSelectedImage] = useState(0); const [localQuantity, setLocalQuantity] = useState(1); const [isFavorite, setIsFavorite] = useState(false); const [isSyncing, setIsSyncing] = useState(false); const [syncError, setSyncError] = useState(false); const [showStockModal, setShowStockModal] = useState(false); + const [showReviewModal, setShowReviewModal] = useState(false); const t = useTranslations(); @@ -63,24 +52,30 @@ 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, + refetch: refetchProduct, } = useProductsBySlug(slug); + const { data: cartData, refetch: refetchCart } = useCart(); + + const { data: relatedProducts } = useRelatedProducts(product?.id || 0, { + enabled: !!product?.id, + }); + const addToCartMutation = useAddToCart(); const updateCartMutation = useUpdateCartItemQuantity(); const removeFromCartMutation = useRemoveFromCart(); + const submitReviewMutation = useSubmitReview(); const cartItem = useMemo( () => cartData?.data?.find((item: any) => item.product?.id === product?.id), [cartData, product] ); const isInCart = !!cartItem; - const availableStock = product?.stock || 0; const imageUrls = useMemo( @@ -91,42 +86,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { [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] + // ✅ CORRECT - Use reviews from product data + const reviews = useMemo(() => product?.reviews_resources || [], [product]); + const averageRating = useMemo( + () => (product?.reviews?.rating ? parseFloat(product.reviews.rating) : 0), + [product] ); useEffect(() => { @@ -138,19 +102,16 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { const savePendingUpdate = useCallback( (quantity: number) => { if (!product?.id) return; - try { const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY); const pending: Record = stored ? JSON.parse(stored) : {}; - pending[product.id] = { quantity, timestamp: Date.now(), retryCount: retryCountRef.current, }; - sessionStorage.setItem( PENDING_PRODUCT_UPDATES_KEY, JSON.stringify(pending) @@ -164,13 +125,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { const clearPendingUpdate = useCallback(() => { if (!product?.id) return; - try { const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY); if (stored) { const pending: Record = JSON.parse(stored); delete pending[product.id]; - if (Object.keys(pending).length === 0) { sessionStorage.removeItem(PENDING_PRODUCT_UPDATES_KEY); } else { @@ -225,7 +184,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setSyncError(false); try { - // If quantity is 0, remove from cart if (quantity === 0) { await removeFromCartMutation.mutateAsync(product.id); toast.success(t("removed_from_cart")); @@ -245,7 +203,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setIsSyncing(false); retryCountRef.current = 0; clearPendingUpdate(); - await refetchCart(); if (pendingQuantityRef.current !== null) { @@ -340,7 +297,6 @@ 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); }; }, []); @@ -356,7 +312,6 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { }); await refetchCart(); - setIsSyncing(false); toast.success(t("added_to_cart"), { @@ -376,14 +331,11 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setShowStockModal(true); return; } - setLocalQuantity((prev) => prev + 1); }, [localQuantity, availableStock]); const handleQuantityDecrease = useCallback(() => { - // Allow decreasing to 0 to remove from cart if (localQuantity <= 0) return; - setLocalQuantity((prev) => prev - 1); }, [localQuantity]); @@ -391,6 +343,37 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { setIsFavorite(!isFavorite); }, [isFavorite]); + const handleSubmitReview = useCallback( + async (rating: number, text: string) => { + if (!product?.id || rating === 0 || !text.trim()) { + toast.error(t("error"), { + description: "Please provide rating and review text", + }); + return; + } + + try { + await submitReviewMutation.mutateAsync({ + productId: product.id, + rating: rating, + title: text, + source: "site", + }); + + // ✅ Refetch product to get updated reviews + await refetchProduct(); + + toast.success("Review submitted successfully!"); + setShowReviewModal(false); + } catch (error) { + toast.error(t("error"), { + description: "Failed to submit review", + }); + } + }, + [product?.id, submitReviewMutation, refetchProduct, t] + ); + const loadingSkeleton = useMemo( () => (
@@ -413,9 +396,7 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { [] ); - if (productLoading) { - return loadingSkeleton; - } + if (productLoading) return loadingSkeleton; if (error || !product) { return ( @@ -434,318 +415,67 @@ export default function ProductPageContent({ slug }: ProductDetailProps) { <>
-
-
-
- {imageUrls.length > 0 ? ( - {product.name} - ) : ( -
- {t("no_image")} -
- )} -
+ - {imageUrls.length > 1 && ( -
- {imageUrls.map((image, index) => ( - - ))} -
- )} -
-
+ -
-
-

{product.name}

-
- - -

- {t("about_product")} -

-
- {product.brand?.name && ( - <> -
- {t("brands")} - {product.brand.name} -
- - - )} - - {product.stock !== undefined && ( - <> -
- {t("stock")} - - {product.stock === 0 - ? t("out_of_stock") - : product.stock <= 5 - ? `${t("only_left", { count: product.stock })}` - : product.stock} - -
- - - )} - - {product.barcode && ( - <> -
- {t("barcode")} - - {product.barcode} - -
- - - )} - - {product.colour && ( - <> -
- {t("color")} - {product.colour} -
- - - )} - - {product.properties && product.properties.length > 0 && ( - <> - {product.properties.map( - (prop, idx) => - prop.value && ( -
-
- {prop.name} - {prop.value} -
- {idx < product.properties.length - 1 && ( - - )} -
- ) - )} - - )} -
-
- - {product.description && ( - -

- {t("product_description")} -

-
- - )} -
- -
- -
- {t("price")}: -
- - {product.price_amount} TMT - - {product.old_price_amount && - parseFloat(product.old_price_amount) > 0 && ( - - {product.old_price_amount} TMT - - )} -
-
- -
- {isInCart ? ( - <> - - - - -
- -
- {localQuantity} - - {syncError && ( - - )} -
- - - -
- - ) : ( - - )} -
-
- - {product.channel && product.channel.length > 0 && ( - -
- - - - - -
-

{t("store")}

-

- {product.channel[0].name} -

-
-
- -
- )} -
+
+ + setShowReviewModal(true)} + /> + +
- - - -
-
- -
-
- - {t("stock_limit_title")} - - - {t("stock_limit_message", { - product: product.name, - stock: availableStock, - })} - -
-
- -
-
-
+ + + ); -} +} \ No newline at end of file diff --git a/features/products/components/ProductPurchaseCard.tsx b/features/products/components/ProductPurchaseCard.tsx new file mode 100644 index 0000000..2e544dc --- /dev/null +++ b/features/products/components/ProductPurchaseCard.tsx @@ -0,0 +1,172 @@ +import Link from "next/link"; +import { Minus, Plus, Heart, ShoppingCart, Store, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; + +interface ProductPurchaseCardProps { + price: string; + oldPrice?: string; + isInCart: boolean; + localQuantity: number; + availableStock: number; + isSyncing: boolean; + syncError: boolean; + isFavorite: boolean; + productStock: number; + channelName?: string; + onAddToCart: () => void; + onQuantityIncrease: () => void; + onQuantityDecrease: () => void; + onToggleFavorite: () => void; + t: (key: string) => string; +} + +export function ProductPurchaseCard({ + price, + oldPrice, + isInCart, + localQuantity, + availableStock, + isSyncing, + syncError, + isFavorite, + productStock, + channelName, + onAddToCart, + onQuantityIncrease, + onQuantityDecrease, + onToggleFavorite, + t, +}: ProductPurchaseCardProps) { + return ( +
+ +
+ {t("price")}: +
+ + {price} TMT + + {oldPrice && parseFloat(oldPrice) > 0 && ( + + {oldPrice} TMT + + )} +
+
+ +
+ {isInCart ? ( + <> + + + + +
+ +
+ {localQuantity} + {syncError && ( + + )} +
+ + + +
+ + ) : ( + + )} +
+
+ + {channelName && ( + +
+ + + + + +
+

{t("store")}

+

{channelName}

+
+
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/features/products/components/ProductReviewsSection.tsx b/features/products/components/ProductReviewsSection.tsx new file mode 100644 index 0000000..af7b1e8 --- /dev/null +++ b/features/products/components/ProductReviewsSection.tsx @@ -0,0 +1,91 @@ +import { Star, Send } from "lucide-react"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; + +interface Review { + id: number; + rating: number; + title: string; + created_at: string; +} + +interface ProductReviewsSectionProps { + reviews: Review[]; + averageRating: number; + isLoading: boolean; + onWriteReview: () => void; +} + +export function ProductReviewsSection({ + reviews, + averageRating, + isLoading, + onWriteReview, +}: ProductReviewsSectionProps) { + const renderStars = (rating: number) => { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+ ); + }; + + return ( + +
+
+

Customer Reviews

+
+ {renderStars(Math.round(averageRating))} + + {averageRating.toFixed(1)} out of 5 + +
+
+ +
+ + + + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : reviews.length > 0 ? ( +
+ {reviews.map((review) => ( +
+
+
+ {renderStars(review.rating)} + +
+
+

{review.title}

+
+ ))} +
+ ) : ( +
+ No reviews yet. Be the first to review this product! +
+ )} +
+ ); +} \ No newline at end of file diff --git a/features/products/components/RelatedProductsSection.tsx b/features/products/components/RelatedProductsSection.tsx new file mode 100644 index 0000000..e8b30f2 --- /dev/null +++ b/features/products/components/RelatedProductsSection.tsx @@ -0,0 +1,74 @@ +import ProductCard from "@/features/home/components/ProductCard"; + +interface RelatedProduct { + id: number; + slug: string; + name: string; + price_amount: string; + old_price_amount?: string; + struct_price_text: string; + discount?: number | null; + discount_text?: string | null; + stock?: number; + media: Array<{ + images_800x800?: string; + images_720x720?: string; + images_400x400?: string; + thumbnail: string; + }>; + labels?: Array<{ + text: string; + bg_color: string; + }>; + price_color?: string; +} + +interface RelatedProductsSectionProps { + products: RelatedProduct[]; +} + +export function RelatedProductsSection({ + products, +}: RelatedProductsSectionProps) { + if (!products || products.length === 0) return null; + + return ( +
+

Related Products

+
+ {products.slice(0, 4).map((product) => { + // Extract image URLs from media + const images = + product.media?.map( + (m) => + m.images_800x800 || + m.images_720x720 || + m.images_400x400 || + m.thumbnail + ) || []; + + return ( + + ); + })} +
+
+ ); +} diff --git a/features/products/components/ReviewModal.tsx b/features/products/components/ReviewModal.tsx new file mode 100644 index 0000000..3eaecc9 --- /dev/null +++ b/features/products/components/ReviewModal.tsx @@ -0,0 +1,121 @@ +import { useState } from "react"; +import { Star, Send, Loader2 } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface ReviewModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (rating: number, text: string) => Promise; + isSubmitting: boolean; +} + +export function ReviewModal({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: ReviewModalProps) { + const [rating, setRating] = useState(0); + const [text, setText] = useState(""); + const [hoveredStar, setHoveredStar] = useState(0); + + const handleClose = () => { + onOpenChange(false); + setRating(0); + setText(""); + setHoveredStar(0); + }; + + const handleSubmit = async () => { + await onSubmit(rating, text); + handleClose(); + }; + + const renderStars = () => { + return ( +
+ {[1, 2, 3, 4, 5].map((star) => ( + setRating(star)} + onMouseEnter={() => setHoveredStar(star)} + onMouseLeave={() => setHoveredStar(0)} + /> + ))} +
+ ); + }; + + return ( + + + + Write a Review + + Share your experience with this product + + +
+
+ + {renderStars()} +
+
+ +