diff --git a/Soraglar.txt b/Soraglar.txt new file mode 100644 index 0000000..9900e27 --- /dev/null +++ b/Soraglar.txt @@ -0,0 +1,11 @@ +1. Home page category suratlar acanok + +2. Harytlar kem kas bolanu ucin home page doly gorkezenok + +3. Filter nahili isleyar + +4. Order nadip otmen etmeli. + +5. Review feed back yazylyan yer bamy bolmalymy + +6. Open Store api field ler nahili bolmaly. \ No newline at end of file diff --git a/app/[locale]/cart/page.tsx b/app/[locale]/cart/page.tsx index 3b9ae8d..537b7ff 100644 --- a/app/[locale]/cart/page.tsx +++ b/app/[locale]/cart/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import CartItemCard from "../../../features/cart/components/CartItemCard"; @@ -13,7 +13,7 @@ import { import { userStore } from "@/features/profile/userStore"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; -import type { DeliveryType, PaymentType } from "../../../features/cart/types"; +import type { DeliveryType, PaymentType } from "@/lib/types/api"; export default function CartPage() { const [isClient, setIsClient] = useState(false); @@ -22,8 +22,8 @@ export default function CartPage() { const [selectedRegion, setSelectedRegion] = useState(""); const [selectedProvince, setSelectedProvince] = useState(null); const [note, setNote] = useState(""); + const router = useRouter(); - const t = useTranslations(); const { data: cartResponse, isLoading, isError } = useCart(); @@ -37,15 +37,43 @@ export default function CartPage() { setIsClient(true); }, []); - const regionGroups = provinces.reduce((acc, province) => { - if (!acc[province.region]) { - acc[province.region] = []; - } - acc[province.region].push(province); - return acc; - }, {} as Record); + // Memoize region groups to prevent unnecessary recalculations + const regionGroups = useMemo(() => { + return provinces.reduce((acc, province) => { + if (!acc[province.region]) { + acc[province.region] = []; + } + acc[province.region].push(province); + return acc; + }, {} as Record); + }, [provinces]); - const availableRegions = Object.keys(regionGroups); + const availableRegions = useMemo(() => Object.keys(regionGroups), [regionGroups]); + + // Memoize items grouped by seller + const itemsBySeller = useMemo(() => { + return cartItems.reduce((acc, item) => { + const sellerId = item.product.channel?.[0]?.id || 0; + const sellerName = item.product.channel?.[0]?.name || "Unknown Seller"; + + if (!acc[sellerId]) { + acc[sellerId] = { + seller: { id: sellerId, name: sellerName }, + items: [], + }; + } + acc[sellerId].items.push(item); + return acc; + }, {} as Record); + }, [cartItems]); + + // Memoize total amount + const totalAmount = useMemo(() => { + return cartItems.reduce((sum, item) => { + const price = parseFloat(item.product.price_amount || "0"); + return sum + price * item.product_quantity; + }, 0); + }, [cartItems]); const handleDeliveryTypeChange = (type: DeliveryType) => { setDeliveryType(type); @@ -61,7 +89,6 @@ export default function CartPage() { const selectedProvinceData = provinces.find((p) => p.id === selectedProvince); if (!selectedProvinceData) return; - // Kullanıcı bilgilerini store'dan al const orderData = userStore.getOrderData(); if (!orderData) { console.error("User data not found"); @@ -92,7 +119,7 @@ export default function CartPage() { if (isLoading) { return (
-

{t("loading")}

+

{t("common.loading")}

); } @@ -101,137 +128,80 @@ export default function CartPage() { return (

- {t("emptyCart")} + {t("cart_empty")}

); } - const translations = { - cart: t("cart"), - ordersIn: t("order_available_in_shops"), - pricePerUnit: t("unit_price"), - additionalPrice: t("extra_price"), - discount: t("discount"), - totalPrice: t("total_price"), - paymentType: t("payment_type"), - cash: t("cash"), - card: t("card"), - deliveryType: t("delivery_type"), - delivery: t("delivery"), - pickup: t("pickup"), - selectRegion: t("choose_region"), - selectAddress: t("choose_address"), - note: t("note"), - placeOrder: t("order"), - emptyCart: t("cart_empty"), - map: t("address"), - }; - - const itemsBySeller = cartItems.reduce((acc, item) => { - const sellerId = item.product.channel?.[0]?.id || 0; - const sellerName = item.product.channel?.[0]?.name || "Unknown Seller"; - - if (!acc[sellerId]) { - acc[sellerId] = { - seller: { id: sellerId, name: sellerName }, - items: [], - }; - } - acc[sellerId].items.push(item); - return acc; - }, {} as Record); - - const totalAmount = cartItems.reduce((sum, item) => { - const price = parseFloat(item.product.price_amount || "0"); - return sum + price * item.product_quantity; - }, 0); - return (
-

{translations.cart}

+

{t("cart")}

- {Object.entries(itemsBySeller).map( - ([sellerId, { seller, items }]) => ( -
-

{seller.name}

-
- {items.map((item) => { - const price = parseFloat(item.product.price_amount || "0"); - const quantity = item.product_quantity; - const total = price * quantity; + {Object.entries(itemsBySeller).map(([sellerId, { seller, items }]) => ( +
+

{seller.name}

+
+ {items.map((item) => { + const price = parseFloat(item.product.price_amount || "0"); + const quantity = item.product_quantity; + const total = price * quantity; - return ( - m.images_800x800 || m.thumbnail - ) || [], - }, - }} - translations={translations} - /> - ); - })} -
- {Object.entries(itemsBySeller).length > 1 && ( - - )} + return ( + m.images_800x800 || m.thumbnail + ) || [], + }, + }} + /> + ); + })}
- ) - )} + {Object.entries(itemsBySeller).length > 1 && ( + + )} +
+ ))}
({ - ...item, - quantity: item.product_quantity, - price: parseFloat(item.product.price_amount || "0"), - total: - parseFloat(item.product.price_amount || "0") * - item.product_quantity, - seller: { - id: item.product.channel?.[0]?.id || 0, - name: item.product.channel?.[0]?.name || "Unknown", - }, - })), billing: { body: [ { - title: t("goods"), + title: t("products"), value: `${totalAmount.toFixed(2)} TMT`, }, ], footer: { - title: t("total"), + title: t("total_price"), value: `${totalAmount.toFixed(2)} TMT`, }, }, }} - translations={translations} paymentType={paymentType} deliveryType={deliveryType} selectedRegion={selectedRegion} diff --git a/app/[locale]/favorites/page.tsx b/app/[locale]/favorites/page.tsx index 0c84fea..33fe7cc 100644 --- a/app/[locale]/favorites/page.tsx +++ b/app/[locale]/favorites/page.tsx @@ -4,7 +4,7 @@ import { useAddToCart, useRemoveFromFavorites, } from "@/lib/hooks"; -import { useState } from "react"; +import { useState, useCallback, useMemo } from "react"; import Image from "next/image"; import Link from "next/link"; import { Heart, ShoppingCart } from "lucide-react"; @@ -13,82 +13,77 @@ import { Card } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { useToast } from "@/hooks/use-toast"; +import { useTranslations } from "next-intl"; import type { Favorite } from "@/lib/types/api"; export default function FavoritesPage() { const [isHovered, setIsHovered] = useState(null); const { toast } = useToast(); + const t = useTranslations(); const { data: favorites, isLoading, isError } = useFavorites(); const { mutate: removeFromFavorites, isPending: isRemoving } = useRemoveFromFavorites(); const { mutate: addToCart, isPending: isAddingToCart } = useAddToCart(); - const t = { - favorites: "Избранные", - addToCart: "В корзину", - emptyFavorites: "У вас пока нет избранных товаров", - removedFromFavorites: "Товар удален из избранного", - addedToCart: "Товар добавлен в корзину", - error: "Произошла ошибка", - }; - - const handleRemoveFromFavorites = (productId: number) => { + const handleRemoveFromFavorites = useCallback((productId: number) => { removeFromFavorites(productId, { onSuccess: () => { toast({ - title: t.removedFromFavorites, + title: t("removed_from_favorites"), }); }, onError: (error) => { toast({ - title: t.error, + title: t("error"), description: error.message, variant: "destructive", }); }, }); - }; + }, [removeFromFavorites, toast, t]); - const handleAddToCart = (productId: number) => { + const handleAddToCart = useCallback((productId: number) => { addToCart( { productId }, { onSuccess: () => { toast({ - title: t.addedToCart, + title: t("added_to_cart"), }); }, onError: (error) => { toast({ - title: t.error, + title: t("error"), description: error.message, variant: "destructive", }); }, } ); - }; + }, [addToCart, toast, t]); + + const loadingSkeleton = useMemo(() => ( +
+

{t("favorite_products")}

+
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+ ), [t]); if (isLoading) { - return ( -
-

{t.favorites}

-
- {Array.from({ length: 10 }).map((_, i) => ( - - ))} -
-
- ); + return loadingSkeleton; } if (isError || !favorites || favorites.length === 0) { return (
-

{t.favorites}

+

{t("favorite_products")}

-

{t.emptyFavorites}

+

{t("empty_favorites")}

); @@ -96,7 +91,7 @@ export default function FavoritesPage() { return (
-

{t.favorites}

+

{t("favorite_products")}

{favorites.map((favorite: Favorite) => ( ))}
@@ -142,7 +136,6 @@ interface ProductCardProps { isHovered: boolean; isRemoving: boolean; isAddingToCart: boolean; - translations: { addToCart: string }; } function ProductCard({ @@ -154,21 +147,17 @@ function ProductCard({ isHovered, isRemoving, isAddingToCart, - translations, }: ProductCardProps) { + const t = useTranslations(); + if (!product) return null; - // Получаем первое изображение из media const imageUrl = product.media?.[0]?.images_800x800 || product.media?.[0]?.thumbnail || "/placeholder.svg"; - // Форматируем цену - const price = product.old_price_amount - ? `${parseFloat(product.price_amount).toFixed(2)} TMT` - : `${parseFloat(product.price_amount).toFixed(2)} TMT`; - + const price = `${parseFloat(product.price_amount).toFixed(2)} TMT`; const oldPrice = product.old_price_amount ? `${parseFloat(product.old_price_amount).toFixed(2)} TMT` : null; @@ -179,7 +168,7 @@ function ProductCard({ onMouseEnter={() => onHover(productId)} onMouseLeave={() => onHover(null)} > - +
{/* Favorite Button */}
)} @@ -241,10 +230,10 @@ function ProductCard({ size="sm" > - {translations.addToCart} + {t("add_to_cart")}
)}
); -} +} \ No newline at end of file diff --git a/app/[locale]/openStore/page.tsx b/app/[locale]/openStore/page.tsx index 667729b..96c4a45 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]/product/[slug]/page.tsx b/app/[locale]/product/[slug]/page.tsx index a42e563..f79947c 100644 --- a/app/[locale]/product/[slug]/page.tsx +++ b/app/[locale]/product/[slug]/page.tsx @@ -1,15 +1,15 @@ -import type { Metadata } from "next" -import { notFound } from "next/navigation" -import ProductPageContent from "../../../../features/products/components/ProductPageContent" +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import ProductPageContent from "../../../../features/products/components/ProductPageContent"; type Props = { - params: Promise<{ locale: string; slug: string }> -} + params: Promise<{ locale: string; slug: string }>; +}; -export const revalidate = 3600 // ISR: Revalidate every hour +export const revalidate = 3600; // ISR: Revalidate every hour export async function generateMetadata({ params }: Props): Promise { - const { locale, slug } = await params + const { locale, slug } = await params; return { title: `Product ${slug} | E-Commerce`, @@ -20,20 +20,20 @@ export async function generateMetadata({ params }: Props): Promise { title: `Product ${slug} | E-Commerce`, description: `View details for product ${slug}`, }, - } + }; } export async function generateStaticParams() { // Generate static params for popular products - return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }] + return [{ slug: "nike-air-max" }, { slug: "adidas-ultraboost" }]; } export default async function ProductPage(props: Props) { - const params = await props.params + const params = await props.params; if (!params.slug) { - notFound() + notFound(); } - return -} + return ; +} \ No newline at end of file diff --git a/components/empty-states/EmptySearch.tsx b/components/empty-states/EmptySearch.tsx deleted file mode 100644 index 3088cbe..0000000 --- a/components/empty-states/EmptySearch.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Search } from "lucide-react" -import { Button } from "@/components/ui/button" -import Link from "next/link" - -interface EmptySearchProps { - locale?: string - query?: string - message?: string - actionText?: string - actionHref?: string -} - -export default function EmptySearch({ - locale = "ru", - query = "", - message = "No results found", - actionText = "Back to Home", - actionHref = "/", -}: EmptySearchProps) { - return ( -
- -

{message}

- {query && ( -

- {locale === "ru" ? `No products found for "${query}"` : `No products found for "${query}"`} -

- )} - - - -
- ) -} diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx index a60570c..e151051 100644 --- a/components/layout/Header.tsx +++ b/components/layout/Header.tsx @@ -1,108 +1,64 @@ -"use client" +"use client"; -import { useState, useEffect } from "react" -import Link from "next/link" -import Image from "next/image" -import { X, Menu, Search, Store, LogOut, User as UserIcon } from "lucide-react" -import { Button } from "@/components/ui/button" +import { useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import Image from "next/image"; +import { X, Menu, Search, Store, LogOut, User as UserIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import Logo from "@/public/logo.png" -import CategoryMenu from "./ui/CategoryMenu" -import SearchBar from "./ui/SearchBar" -import AuthDialog from "./ui/AuthDialog" -import ActionButtons from "./ui/ActionButtons" -import LanguageSelector from "./ui/LanguageSelector" -import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth" +} from "@/components/ui/dropdown-menu"; +import Logo from "@/public/logo.png"; +import CategoryMenu from "./ui/CategoryMenu"; +import SearchBar from "./ui/SearchBar"; +import AuthDialog from "./ui/AuthDialog"; +import ActionButtons from "./ui/ActionButtons"; +import LanguageSelector from "./ui/LanguageSelector"; +import { useAuthStatus, useLogout } from "@/lib/hooks/useAuth"; +import { useTranslations } from "next-intl"; interface HeaderProps { - locale?: string - translations?: { - catalog: string - search: string - orders: string - favorites: string - cart: string - login: string - profile: string - openStore: string - phone: string - code: string - send: string - verify: string - sending: string - verifying: string - enterPhone: string - weWillSendCode: string - invalidPhone: string - invalidCode: string - loginSuccess: string - codeSent: string - logout: string - loggingOut: string - } + locale?: string; } -const DEFAULT_TRANSLATIONS = { - catalog: "Каталог", - search: "Поиск продукта", - orders: "Заказы", - favorites: "Избранное", - cart: "Корзина", - login: "Войти", - profile: "Профиль", - openStore: "Открыть магазин", - phone: "Номер телефона", - code: "Код", - send: "Отправить", - verify: "Подтвердить", - sending: "Отправка...", - verifying: "Проверка...", - enterPhone: "Введите свой номер телефона", - weWillSendCode: "Мы вышлем вам код", - invalidPhone: "Неверный номер телефона", - invalidCode: "Неверный код", - loginSuccess: "Вход выполнен успешно", - codeSent: "Код отправлен на ваш номер", - logout: "Выйти", - loggingOut: "Выход...", -} +export default function Header({ locale = "ru" }: HeaderProps) { + const [isClient, setIsClient] = useState(false); + const [isCategoryOpen, setIsCategoryOpen] = useState(false); + const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); + const [isLoginOpen, setIsLoginOpen] = useState(false); + const t = useTranslations(); -export default function Header({ locale = "ru", translations }: HeaderProps) { - const [isClient, setIsClient] = useState(false) - const [isCategoryOpen, setIsCategoryOpen] = useState(false) - const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false) - const [isLoginOpen, setIsLoginOpen] = useState(false) - - const t = { ...DEFAULT_TRANSLATIONS, ...translations } - - const { isAuthenticated, isLoading } = useAuthStatus() - const { mutate: logout, isPending: isLoggingOut } = useLogout() + const { isAuthenticated, isLoading } = useAuthStatus(); + const { mutate: logout, isPending: isLoggingOut } = useLogout(); useEffect(() => { - setIsClient(true) - }, []) + setIsClient(true); + }, []); - const handleAuthClick = () => { + const handleAuthClick = useCallback(() => { if (isAuthenticated) { - window.location.href = `/${locale}/me` + window.location.href = `/${locale}/me`; } else { - setIsLoginOpen(true) + setIsLoginOpen(true); } - } + }, [isAuthenticated, locale]); - const handleLogout = () => { - logout() - } + const handleLogout = useCallback(() => { + logout(); + }, [logout]); - const toggleCategoryMenu = () => setIsCategoryOpen(!isCategoryOpen) - const closeCategoryMenu = () => setIsCategoryOpen(false) + const toggleCategoryMenu = useCallback(() => { + setIsCategoryOpen((prev) => !prev); + }, []); - if (!isClient) return null + const closeCategoryMenu = useCallback(() => { + setIsCategoryOpen(false); + }, []); + + if (!isClient) return null; return ( <> @@ -121,7 +77,7 @@ export default function Header({ locale = "ru", translations }: HeaderProps) { size="lg" > {isCategoryOpen ? : } - {t.catalog} + {t("common.catalog")}
@@ -135,55 +91,25 @@ export default function Header({ locale = "ru", translations }: HeaderProps) {
- + -
- {isLoading ? ( -
- ) : isAuthenticated ? ( - - - - - - (window.location.href = `/${locale}/me`)}> - - {t.profile} - - - - {isLoggingOut ? t.loggingOut : t.logout} - - - - ) : ( - - )} -
+
@@ -195,27 +121,14 @@ export default function Header({ locale = "ru", translations }: HeaderProps) { isMobile={true} isOpen={isMobileSearchOpen} onClose={() => setIsMobileSearchOpen(false)} - searchPlaceholder={t.search} + searchPlaceholder={t("common.search")} + locale={locale} /> setIsLoginOpen(false)} - translations={{ - enterPhone: t.enterPhone, - weWillSendCode: t.weWillSendCode, - phone: t.phone, - code: t.code, - send: t.send, - verify: t.verify, - sending: t.sending, - verifying: t.verifying, - invalidPhone: t.invalidPhone, - invalidCode: t.invalidCode, - loginSuccess: t.loginSuccess, - codeSent: t.codeSent, - }} /> - ) + ); } \ No newline at end of file diff --git a/components/layout/ui/ActionButtons.tsx b/components/layout/ui/ActionButtons.tsx index 95bc5f4..902bf93 100644 --- a/components/layout/ui/ActionButtons.tsx +++ b/components/layout/ui/ActionButtons.tsx @@ -1,75 +1,134 @@ -"use client" +"use client"; -import type React from "react" -import Link from "next/link" -import { User, Truck, Heart, ShoppingCart } from "lucide-react" -import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" -import { useCart, useFavorites, useOrders } from "@/lib/hooks" -import { Skeleton } from "@/components/ui/skeleton" +import { useMemo } from "react"; +import type React from "react"; +import Link from "next/link"; +import { User, Truck, Heart, ShoppingCart, LogOut } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useCart, useFavorites, useOrders } from "@/lib/hooks"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useTranslations } from "next-intl"; +import { useLogout } from "@/lib/hooks/useAuth"; interface ActionButtonsProps { - isAuthenticated: boolean - onAuthClick: () => void - translations: { - profile: string - login: string - orders: string - favorites: string - cart: string - } + isAuthenticated: boolean; + onAuthClick: () => void; + isLoading?: boolean; + locale?: string; } interface ActionButtonData { - icon: React.ReactNode - label: string - href?: string - onClick?: () => void - badgeCount?: number - isLoading?: boolean + icon: React.ReactNode; + label: string; + href?: string; + onClick?: () => void; + badgeCount?: number; + isLoading?: boolean; } -export default function ActionButtons({ isAuthenticated, onAuthClick, translations: t }: ActionButtonsProps) { - const { data: cartData, isLoading: cartLoading } = useCart() - const { data: favoritesData, isLoading: favoritesLoading } = useFavorites() - const { data: ordersData, isLoading: ordersLoading } = useOrders() +export default function ActionButtons({ + isAuthenticated, + onAuthClick, + isLoading: authLoading, + locale = "ru" +}: ActionButtonsProps) { + const t = useTranslations(); + const { mutate: logout, isPending: isLoggingOut } = useLogout(); + + const { data: cartData, isLoading: cartLoading } = useCart(); + const { data: favoritesData, isLoading: favoritesLoading } = useFavorites(); + const { data: ordersData, isLoading: ordersLoading } = useOrders(); - const buttons: ActionButtonData[] = [ - { - icon: , - label: isAuthenticated ? t.profile : t.login, - onClick: onAuthClick, - }, + // Calculate cart count from cart items array + const cartCount = useMemo(() => { + if (!cartData?.data) return 0; + return cartData.data.length; + }, [cartData]); + + // Calculate favorites count + const favoritesCount = useMemo(() => { + if (!favoritesData) return 0; + return Array.isArray(favoritesData) ? favoritesData.length : 0; + }, [favoritesData]); + + // Calculate orders count + const ordersCount = useMemo(() => { + if (!ordersData) return 0; + return Array.isArray(ordersData) ? ordersData.length : 0; + }, [ordersData]); + + const handleLogout = () => { + logout(); + }; + + const buttons: ActionButtonData[] = useMemo(() => [ { icon: , - label: t.orders, + label: t("common.orders"), href: "/orders", - badgeCount: ordersData?.length || 0, + badgeCount: ordersCount, isLoading: ordersLoading, }, { icon: , - label: t.favorites, + label: t("common.favorites"), href: "/favorites", - badgeCount: favoritesData?.length || 0, + badgeCount: favoritesCount, isLoading: favoritesLoading, }, { icon: , - label: t.cart, + label: t("common.cart"), href: "/cart", - badgeCount: cartData?.count || 0, + badgeCount: cartCount, isLoading: cartLoading, }, - ] + ], [ordersCount, ordersLoading, favoritesCount, favoritesLoading, cartCount, cartLoading, t]); return (
+ {/* Profile/Login Button with Dropdown */} + {authLoading ? ( +
+ ) : isAuthenticated ? ( + + + + + + (window.location.href = `/${locale}/me`)}> + + {t("profile")} + + + + {isLoggingOut ? t("logging_out") : t("common.logout")} + + + + ) : ( + + )} + + {/* Other Action Buttons */} {buttons.map((button, index) => ( ))}
- ) + ); } function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: ActionButtonData) { @@ -77,7 +136,7 @@ function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: Act - ) + ); if (href) { - return {buttonContent} + return {buttonContent}; } - return buttonContent -} + return buttonContent; +} \ No newline at end of file diff --git a/components/layout/ui/AuthDialog.tsx b/components/layout/ui/AuthDialog.tsx index 131ac1c..422e51c 100644 --- a/components/layout/ui/AuthDialog.tsx +++ b/components/layout/ui/AuthDialog.tsx @@ -1,79 +1,67 @@ -"use client" +"use client"; -import React, { useState } from "react" -import Image from "next/image" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" -import { toast } from "sonner" -import Logo from "@/public/logo.png" -import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth" +import { useState, useCallback } from "react"; +import Image from "next/image"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import Logo from "@/public/logo.png"; +import { useLogin, useVerifyToken } from "@/lib/hooks/useAuth"; +import { useTranslations } from "next-intl"; interface AuthDialogProps { - isOpen: boolean - onClose: () => void - translations: { - enterPhone: string - weWillSendCode: string - phone: string - code: string - send: string - verify: string - sending: string - verifying: string - invalidPhone: string - invalidCode: string - loginSuccess: string - codeSent: string - } + isOpen: boolean; + onClose: () => void; } -export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDialogProps) { - const [phone, setPhone] = useState("993") - const [otp, setOtp] = useState("") - const [otpSent, setOtpSent] = useState(false) - const [rawPhone, setRawPhone] = useState("") +export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) { + const [phone, setPhone] = useState("993"); + const [otp, setOtp] = useState(""); + const [otpSent, setOtpSent] = useState(false); + const [rawPhone, setRawPhone] = useState(""); + const t = useTranslations(); - const { mutate: login, isPending: isLoginLoading } = useLogin() - const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken() + const { mutate: login, isPending: isLoginLoading } = useLogin(); + const { mutate: verifyToken, isPending: isVerifyLoading } = useVerifyToken(); - const resetDialog = () => { - setOtpSent(false) - setPhone("993") - setOtp("") - setRawPhone("") - onClose() - } + const resetDialog = useCallback(() => { + setOtpSent(false); + setPhone("993"); + setOtp(""); + setRawPhone(""); + onClose(); + }, [onClose]); - const handleSendOtp = () => { - const cleanPhone = phone.replace(/\D/g, "") + const handleSendOtp = useCallback(() => { + const cleanPhone = phone.replace(/\D/g, ""); if (cleanPhone.length !== 11 || !cleanPhone.startsWith("993")) { - toast.error(t.invalidPhone) - return + toast.error(t("invalid_phone")); + return; } - const phoneNumber = cleanPhone.substring(3) - setRawPhone(phoneNumber) + const phoneNumber = cleanPhone.substring(3); + setRawPhone(phoneNumber); login( { phone_number: phoneNumber }, { onSuccess: () => { - toast.success(t.codeSent) - setOtpSent(true) + toast.success(t("code_sent")); + setOtpSent(true); }, onError: (error: any) => { - toast.error(error?.response?.data?.message || "Hata oluştu") + toast.error(error?.response?.data?.message || t("error_occurred")); }, } - ) - } + ); + }, [phone, login, t]); - const handleLogin = () => { + const handleLogin = useCallback(() => { if (otp.length < 4) { - toast.error(t.invalidCode) - return + toast.error(t("invalid_code")); + return; } verifyToken( @@ -83,30 +71,30 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia }, { onSuccess: () => { - toast.success(t.loginSuccess) - resetDialog() - window.location.reload() + toast.success(t("login_success")); + resetDialog(); + window.location.reload(); }, onError: (error: any) => { - toast.error(error?.response?.data?.message || "Kod yanlış") + toast.error(error?.response?.data?.message || t("wrong_code")); }, } - ) - } + ); + }, [otp, rawPhone, verifyToken, resetDialog, t]); - const handleKeyPress = (e: React.KeyboardEvent, action: () => void) => { + const handleKeyPress = useCallback((e: React.KeyboardEvent, action: () => void) => { if (e.key === "Enter") { - action() + action(); } - } + }, []); - const formatPhoneInput = (value: string) => { - const cleaned = value.replace(/\D/g, "") + const formatPhoneInput = useCallback((value: string) => { + const cleaned = value.replace(/\D/g, ""); if (!cleaned.startsWith("993")) { - return "993" + return "993"; } - return cleaned.substring(0, 11) - } + return cleaned.substring(0, 11); + }, []); return ( @@ -117,15 +105,15 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia Logo
- {t.enterPhone} -

{t.weWillSendCode}

+ {t("common.enterPhone")} +

{t("common.weWillSendCode")}

setPhone(formatPhoneInput(e.target.value))} className="h-12 rounded-xl" @@ -133,13 +121,13 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia disabled={otpSent || isLoginLoading} maxLength={11} /> -

Format: 99365123456

+

{t("phone_format")}

{otpSent && ( setOtp(e.target.value.replace(/\D/g, "").substring(0, 6))} className="h-12 rounded-xl" @@ -157,15 +145,15 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia disabled={isLoginLoading || isVerifyLoading} > {isLoginLoading - ? t.sending + ? t("sending") : isVerifyLoading - ? t.verifying + ? t("verifying") : otpSent - ? t.verify - : t.send} + ? t("verify") + : t("common.send")}
- ) + ); } \ No newline at end of file diff --git a/components/layout/ui/CategoryMenu.tsx b/components/layout/ui/CategoryMenu.tsx index b3c5704..1e90de5 100644 --- a/components/layout/ui/CategoryMenu.tsx +++ b/components/layout/ui/CategoryMenu.tsx @@ -5,13 +5,7 @@ import Link from "next/link" import { useCategories } from "@/lib/hooks" import { Skeleton } from "@/components/ui/skeleton" -interface Category { - id: number - name: string - slug: string - icon_class?: string - children?: Category[] -} + interface CategoryMenuProps { isOpen: boolean diff --git a/components/layout/ui/SearchBar.tsx b/components/layout/ui/SearchBar.tsx index 6128b6b..30eea7d 100644 --- a/components/layout/ui/SearchBar.tsx +++ b/components/layout/ui/SearchBar.tsx @@ -1,5 +1,7 @@ -import React, { useState } from "react"; -import { Search } from "lucide-react"; +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Search, X, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -8,6 +10,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { useRouter } from "next/navigation"; +import { useSearchProducts } from "@/features/search/hooks/useSearch"; +import Image from "next/image"; interface SearchBarProps { isMobile: boolean; @@ -15,6 +20,7 @@ interface SearchBarProps { isOpen?: boolean; onClose?: () => void; className?: string; + locale?: string; } export default function SearchBar({ @@ -23,12 +29,89 @@ export default function SearchBar({ isOpen, onClose, className = "", + locale = "ru", }: SearchBarProps) { + const router = useRouter(); const [searchValue, setSearchValue] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [showResults, setShowResults] = useState(false); + const searchRef = useRef(null); + + const { data, isLoading } = useSearchProducts({ q: debouncedSearch }); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(searchValue); + }, 300); + + return () => clearTimeout(timer); + }, [searchValue]); + + useEffect(() => { + if (debouncedSearch && data?.data && data.data.length > 0) { + setShowResults(true); + } else { + setShowResults(false); + } + }, [debouncedSearch, data]); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(e.target as Node)) { + setShowResults(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); const handleSearch = (value: string) => { setSearchValue(value); - // Here you can add search logic or API call + }; + + const handleProductClick = (productId: number) => { + router.push(`/${locale}/product/${productId}`); + setSearchValue(""); + setShowResults(false); + if (onClose) onClose(); + }; + + const handleClearSearch = () => { + setSearchValue(""); + setShowResults(false); + }; + + const SearchResults = () => { + if (!showResults || !data?.data) return null; + + return ( +
+ {data.data.map((product) => ( + + ))} +
+ ); }; if (isMobile) { @@ -38,15 +121,19 @@ export default function SearchBar({ {searchPlaceholder} -
+
handleSearch(e.target.value)} className="h-10 rounded-xl focus:border-[#005bff] focus-visible:border-[#005bff] focus-visible:ring-0 active:border-[#005bff]" autoFocus /> + {isLoading && ( + + )} +
@@ -54,15 +141,18 @@ export default function SearchBar({ } return ( -
-
+
+
handleSearch(e.target.value)} className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2" /> + {isLoading && ( + + )}
+
); } \ No newline at end of file diff --git a/components/skeletons/PageLoader.tsx b/components/skeletons/PageLoader.tsx deleted file mode 100644 index 5b4719a..0000000 --- a/components/skeletons/PageLoader.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton" -import ProductGridSkeleton from "./ProductGridSkeleton" -import CartItemSkeleton from "./CartItemSkeleton" // Added import for CartItemSkeleton - -interface PageLoaderProps { - /** - * Type of page loading skeleton - * home, products, category, search, cart, favorites, orders, profile - */ - type?: "home" | "products" | "category" | "search" | "cart" | "favorites" | "orders" | "profile" -} - -export default function PageLoader({ type = "products" }: PageLoaderProps) { - switch (type) { - case "home": - return ( -
- {/* Hero Banner */} - - - {/* Categories */} -
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- - {/* Products */} -
- - -
-
- ) - - case "products": - case "search": - return ( -
-
- -
- -
- ) - - case "category": - return ( -
-
- {/* Filters Sidebar */} -
- {Array.from({ length: 3 }).map((_, i) => ( -
- - - - -
- ))} -
- - {/* Products */} -
- -
-
-
- ) - - case "cart": - return ( -
- -
-
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- {/* Order Summary */} -
-
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
-
-
- ) - - case "orders": - case "favorites": - return ( -
- -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
-
- ) - - case "profile": - return ( -
-
- -
- {Array.from({ length: 5 }).map((_, i) => ( -
- - -
- ))} -
-
-
- ) - - default: - return - } -} diff --git a/features/cart/components/CartItemCard.tsx b/features/cart/components/CartItemCard.tsx index ac4e5c7..6cae43d 100644 --- a/features/cart/components/CartItemCard.tsx +++ b/features/cart/components/CartItemCard.tsx @@ -1,176 +1,413 @@ "use client" -import { useState, useEffect, useRef } from "react" +import { useState, useEffect, useRef, useCallback } from "react" import Image from "next/image" -import { Minus, Plus, Trash2 } from "lucide-react" +import { Minus, Plus, Trash2, Loader2, AlertTriangle } from "lucide-react" import { Button } from "@/components/ui/button" import { Card } from "@/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { useUpdateCartItemQuantity, useRemoveFromCart } from "@/lib/hooks" -import type { CartItem, CartTranslations } from "./types" +import { useTranslations } from "next-intl" +import type { CartItem } from "@/lib/types/api" interface CartItemCardProps { item: CartItem - translations: CartTranslations onUpdate?: () => void } -export default function CartItemCard({ item, translations: t, onUpdate }: CartItemCardProps) { +// Session Storage Key +const PENDING_CART_UPDATES_KEY = 'pendingCartUpdates' + +interface PendingUpdate { + quantity: number + timestamp: number + retryCount: number +} + +export default function CartItemCard({ item, onUpdate }: CartItemCardProps) { + const t = useTranslations() + + // Local UI State (Instant feedback) const [localQuantity, setLocalQuantity] = useState(item.quantity) - const [pendingQuantity, setPendingQuantity] = useState(item.quantity) - const [isLoading, setIsLoading] = useState(false) - const updateTimeoutRef = useRef() + + // Sync State + const [isSyncing, setIsSyncing] = useState(false) + const [syncError, setSyncError] = useState(false) + + // Stock limit modal + const [showStockModal, setShowStockModal] = useState(false) + + // Refs + const debounceTimerRef = useRef(undefined) + const isRequestInFlightRef = useRef(false) + const pendingQuantityRef = useRef(null) + const retryCountRef = useRef(0) + const retryTimerRef = useRef(undefined) + + // Function refs to solve circular dependency + const syncToServerRef = useRef<((quantity: number) => void) | null>(null) + const retrySyncRef = useRef<((quantity: number) => void) | null>(null) const { mutate: updateQuantity } = useUpdateCartItemQuantity() const { mutate: removeItem, isPending: isRemoving } = useRemoveFromCart() + // Get available stock + const availableStock = item.product.stock || 0 + + // Initialize from server state useEffect(() => { setLocalQuantity(item.quantity) - setPendingQuantity(item.quantity) }, [item.quantity]) - useEffect(() => { - if (pendingQuantity === item.quantity) return + // Save to sessionStorage + const savePendingUpdate = useCallback((quantity: number) => { + try { + const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) + const pending: Record = stored ? JSON.parse(stored) : {} + + pending[item.product_id] = { + quantity, + timestamp: Date.now(), + retryCount: retryCountRef.current + } + + sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending)) + } catch (error) { + console.error('Failed to save pending update:', error) + } + }, [item.product_id]) - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current) + // Remove from sessionStorage + const clearPendingUpdate = useCallback(() => { + try { + const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) + if (stored) { + const pending: Record = JSON.parse(stored) + delete pending[item.product_id] + + if (Object.keys(pending).length === 0) { + sessionStorage.removeItem(PENDING_CART_UPDATES_KEY) + } else { + sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending)) + } + } + } catch (error) { + console.error('Failed to clear pending update:', error) + } + }, [item.product_id]) + + // Exponential backoff retry + const retrySync = useCallback((quantity: number) => { + const maxRetries = 4 + const retryCount = retryCountRef.current + + if (retryCount >= maxRetries) { + setSyncError(true) + setIsSyncing(false) + return } - updateTimeoutRef.current = setTimeout(() => { - setIsLoading(true) + const delay = Math.min(1000 * Math.pow(2, retryCount), 16000) // Max 16s + retryCountRef.current++ + + retryTimerRef.current = setTimeout(() => { + syncToServerRef.current?.(quantity) + }, delay) + }, []) - if (pendingQuantity <= 0) { - removeItem(item.product_id, { - onSuccess: () => onUpdate?.(), - onError: () => { - setLocalQuantity(item.quantity) - setPendingQuantity(item.quantity) - }, - onSettled: () => setIsLoading(false), - }) - } else { - updateQuantity( - { productId: item.product_id, quantity: pendingQuantity }, - { - onSuccess: () => onUpdate?.(), - onError: () => { - setLocalQuantity(item.quantity) - setPendingQuantity(item.quantity) - }, - onSettled: () => setIsLoading(false), + // Update ref + retrySyncRef.current = retrySync + + // Sync to server + const syncToServer = useCallback((quantity: number) => { + // If already syncing, queue this update + if (isRequestInFlightRef.current) { + pendingQuantityRef.current = quantity + return + } + + // Mark as syncing + isRequestInFlightRef.current = true + setIsSyncing(true) + setSyncError(false) + + if (quantity <= 0) { + removeItem(item.product_id, { + onSuccess: () => { + isRequestInFlightRef.current = false + setIsSyncing(false) + retryCountRef.current = 0 + clearPendingUpdate() + onUpdate?.() + + // Process queued update if any + if (pendingQuantityRef.current !== null) { + const nextQuantity = pendingQuantityRef.current + pendingQuantityRef.current = null + setTimeout(() => syncToServerRef.current?.(nextQuantity), 100) } - ) + }, + onError: (error) => { + console.error('Remove failed:', error) + isRequestInFlightRef.current = false + retrySyncRef.current?.(quantity) + } + }) + } else { + updateQuantity( + { productId: item.product_id, quantity }, + { + onSuccess: () => { + isRequestInFlightRef.current = false + setIsSyncing(false) + retryCountRef.current = 0 + clearPendingUpdate() + onUpdate?.() + + // Process queued update if any + if (pendingQuantityRef.current !== null) { + const nextQuantity = pendingQuantityRef.current + pendingQuantityRef.current = null + setTimeout(() => syncToServerRef.current?.(nextQuantity), 100) + } + }, + onError: (error) => { + console.error('Update failed:', error) + isRequestInFlightRef.current = false + + // Rollback on error after retries exhausted + if (retryCountRef.current >= 3) { + setLocalQuantity(item.quantity) + clearPendingUpdate() + } + + retrySyncRef.current?.(quantity) + } + } + ) + } + }, [item.product_id, item.quantity, updateQuantity, removeItem, onUpdate, clearPendingUpdate]) + + // Update ref + syncToServerRef.current = syncToServer + + // Load pending updates from sessionStorage on mount + useEffect(() => { + const loadPendingUpdates = () => { + try { + const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY) + if (stored) { + const pending: Record = JSON.parse(stored) + const productPending = pending[item.product_id] + + if (productPending && productPending.quantity !== item.quantity) { + // Apply pending update + setLocalQuantity(productPending.quantity) + pendingQuantityRef.current = productPending.quantity + retryCountRef.current = productPending.retryCount + + // Trigger sync after a short delay + setTimeout(() => syncToServerRef.current?.(productPending.quantity), 500) + } + } + } catch (error) { + console.error('Failed to load pending updates:', error) } - }, 300) + } + + loadPendingUpdates() + }, [item.product_id, item.quantity]) + + // Debounced sync + useEffect(() => { + // Clear existing timers + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + } + + // If local quantity matches server, no sync needed + if (localQuantity === item.quantity) { + return + } + + // Save to sessionStorage immediately + savePendingUpdate(localQuantity) + + // Debounce the API call + debounceTimerRef.current = setTimeout(() => { + syncToServerRef.current?.(localQuantity) + }, 800) return () => { - if (updateTimeoutRef.current) { - clearTimeout(updateTimeoutRef.current) + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) } } - }, [pendingQuantity, item.quantity, item.product_id, updateQuantity, removeItem, onUpdate]) + }, [localQuantity, item.quantity, savePendingUpdate]) + + // Cleanup + useEffect(() => { + return () => { + if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) + if (retryTimerRef.current) clearTimeout(retryTimerRef.current) + } + }, []) const handleQuantityIncrease = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - if (isLoading) return - - const newQuantity = localQuantity + 1 - setLocalQuantity(newQuantity) - setPendingQuantity(newQuantity) + + // Check stock limit + if (localQuantity >= availableStock) { + setShowStockModal(true) + return + } + + // Optimistic update (instant UI feedback) + setLocalQuantity(prev => prev + 1) } const handleQuantityDecrease = (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() - if (isLoading) return - - const newQuantity = localQuantity - 1 - if (newQuantity < 1) { + + if (localQuantity <= 1) { handleDelete() return } - - setLocalQuantity(newQuantity) - setPendingQuantity(newQuantity) + + // Optimistic update (instant UI feedback) + setLocalQuantity(prev => prev - 1) } const handleDelete = () => { - setIsLoading(true) - removeItem(item.product_id, { - onSuccess: () => onUpdate?.(), - onSettled: () => setIsLoading(false), - }) + setLocalQuantity(0) + clearPendingUpdate() } const getImageSrc = () => { if (item.product.image) return item.product.image - if (item.product.images?.length > 0) return item.product.images[0] + if (item.product.images && item.product.images.length > 0) return item.product.images[0] return "/placeholder.svg" } return ( - -
-
-
- {item.product.name} -
-
-

{item.product.name}

-

{item.seller?.name || "Store"}

- -
-
- -
-
-

- {t.pricePerUnit} {item.price_formatted} -

-

- {t.additionalPrice} {item.sub_total_formatted} -

- {item.discount_formatted && item.discount_formatted !== "0 TMT" && ( -

{t.discount} {item.discount_formatted}

- )} -
- {t.totalPrice} - - {item.total_formatted} - + <> + +
+
+
+ {item.product.name} +
+
+

{item.product.name}

+

{item.seller?.name || "Store"}

+ {availableStock <= 5 && ( +

+ {t("only_left", { count: availableStock })} +

+ )} +
-
- -
{localQuantity}
- +
+
+

+ {t("unit_price")} {item.price_formatted} +

+

+ {t("extra_price")} {item.sub_total_formatted} +

+ {item.discount_formatted && item.discount_formatted !== "0 TMT" && ( +

{t("discount")} {item.discount_formatted}

+ )} +
+ {t("total_price")} + + {(parseFloat(item.product.price_amount || "0") * localQuantity).toFixed(2)} TMT + +
+
+ +
+ + +
+ {localQuantity} + {isSyncing && ( + + )} + {syncError && ( + + )} +
+ + +
-
-
+ + + {/* Stock Limit Modal */} + + + +
+
+ +
+
+ + {t("stock_limit_title")} + + + {t("stock_limit_message", { + product: item.product.name, + stock: availableStock + })} + +
+
+ +
+
+
+ ) } \ No newline at end of file diff --git a/components/skeletons/CartItemSkeleton.tsx b/features/cart/components/CartItemSkeleton.tsx similarity index 100% rename from components/skeletons/CartItemSkeleton.tsx rename to features/cart/components/CartItemSkeleton.tsx diff --git a/features/cart/components/DeliveryTypeSelector.tsx b/features/cart/components/DeliveryTypeSelector.tsx index d4b6862..3e8022c 100644 --- a/features/cart/components/DeliveryTypeSelector.tsx +++ b/features/cart/components/DeliveryTypeSelector.tsx @@ -1,31 +1,32 @@ "use client" import { Truck, Warehouse } from "lucide-react" import { Card } from "@/components/ui/card" -import { DeliveryType, CartTranslations } from "../types" +import { useTranslations } from "next-intl" +import type { DeliveryType } from "@/lib/types/api" interface DeliveryTypeSelectorProps { selectedType: DeliveryType onSelect: (type: DeliveryType) => void - translations: CartTranslations } export default function DeliveryTypeSelector({ selectedType, onSelect, - translations: t, }: DeliveryTypeSelectorProps) { + const t = useTranslations() + const deliveryOptions: { type: DeliveryType label: string icon: typeof Truck }[] = [ - { type: "SELECTED_DELIVERY", label: t.delivery, icon: Truck }, - { type: "PICK_UP", label: t.pickup, icon: Warehouse }, + { type: "SELECTED_DELIVERY", label: t("delivery"), icon: Truck }, + { type: "PICK_UP", label: t("pickup"), icon: Warehouse }, ] return (
-

{t.deliveryType}

+

{t("delivery_type")}

{deliveryOptions.map(({ type, label, icon: Icon }) => ( - availableRegions: string[] - paymentTypes: PaymentType[] - onPaymentTypeChange: (type: PaymentType) => void - onDeliveryTypeChange: (type: DeliveryType) => void - onRegionChange: (regionCode: string) => void - onProvinceChange: (provinceId: number) => void - onNoteChange: (note: string) => void - onCompleteOrder: () => void - isLoading: boolean + order: { + id: number; + billing: OrderBilling; + }; + paymentType: PaymentType | null; + deliveryType: DeliveryType; + selectedRegion: string; + selectedProvince: number | null; + note: string; + regionGroups: Record; + availableRegions: string[]; + paymentTypes: PaymentType[]; + onPaymentTypeChange: (type: PaymentType) => void; + onDeliveryTypeChange: (type: DeliveryType) => void; + onRegionChange: (regionCode: string) => void; + onProvinceChange: (provinceId: number) => void; + onNoteChange: (note: string) => void; + onCompleteOrder: () => void; + isLoading: boolean; } export default function OrderSummary({ order, - translations: t, paymentType, deliveryType, selectedRegion, @@ -48,29 +69,35 @@ export default function OrderSummary({ onCompleteOrder, isLoading, }: OrderSummaryProps) { - const provincesForSelectedRegion = selectedRegion ? regionGroups[selectedRegion] || [] : [] - const isFormValid = selectedRegion && selectedProvince && paymentType + const t = useTranslations(); + + const provincesForSelectedRegion = selectedRegion + ? regionGroups[selectedRegion] || [] + : []; + const isFormValid = selectedRegion && selectedProvince && paymentType; return ( {/* Payment Type */}
-

{t.paymentType}

+

{t("payment_type")}

{paymentTypes.map((type) => ( onPaymentTypeChange(type)} >
- + {type.name}
@@ -80,21 +107,22 @@ export default function OrderSummary({
{/* Delivery Type */} - {/* Region Selection */}
- - + {t("choose_region")} + + { - onRegionChange(value) - onProvinceChange(null as any) - }} + onRegionChange(value); + onProvinceChange(null as any); + }} className="flex flex-wrap gap-4" > {availableRegions.map((regionCode) => ( @@ -104,7 +132,10 @@ export default function OrderSummary({ id={`region-${regionCode}`} className="border-2 border-gray-400 data-[state=checked]:border-[#005bff] data-[state=checked]:bg-white" /> -
@@ -115,13 +146,15 @@ export default function OrderSummary({ {/* Province Selection */} {selectedRegion && provincesForSelectedRegion.length > 0 && (
- - onProvinceChange(parseInt(value))} > - + {provincesForSelectedRegion.map((province) => ( @@ -136,20 +169,23 @@ export default function OrderSummary({ {/* Note */}
- +