diff --git a/src/features/cart/ui/CartPage.tsx b/src/features/cart/ui/CartPage.tsx index e08141d..0281f83 100644 --- a/src/features/cart/ui/CartPage.tsx +++ b/src/features/cart/ui/CartPage.tsx @@ -36,25 +36,21 @@ const CartPage = () => { select: (data) => data.data.cart_item, }); - const [quantities, setQuantities] = useState>({}); + const [quantities, setQuantities] = useState>({}); const debounceRef = useRef>({}); - // O'lchov birligini formatlash uchun yordamchi funksiya - const getQuantityMessage = (qty: number, measurement: string | null) => { - if (!measurement) return `${qty} dona`; - return `${qty} ${measurement}`; - }; - + // Initial state useEffect(() => { if (!cartItems) return; - const initialQuantities: Record = {}; + const initialQuantities: Record = {}; cartItems.forEach((item) => { - initialQuantities[item.id] = String(item.quantity); + initialQuantities[item.id] = item.quantity; debounceRef.current[item.id] = null; }); setQuantities(initialQuantities); }, [cartItems]); + // Update cart item mutation const { mutate: updateCartItem } = useMutation({ mutationFn: ({ body, @@ -66,18 +62,14 @@ const CartPage = () => { onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['cart_items', cart_id] }); - // Qaysi mahsulot yangilanganini topish const item = cartItems?.find( (i) => String(i.id) === variables.cart_item_id, ); if (item) { const measurementName = item.product.meansurement?.name || null; toast.success( - `${t('Miqdor')} ${getQuantityMessage(variables.body.quantity, measurementName)} ${t('ga yangilandi')}`, - { - richColors: true, - position: 'top-center', - }, + `${t('Miqdor')} ${variables.body.quantity} ${measurementName || 'шт.'} ${t('ga yangilandi')}`, + { richColors: true, position: 'top-center' }, ); } }, @@ -85,6 +77,7 @@ const CartPage = () => { toast.error(err.message, { richColors: true, position: 'top-center' }), }); + // Delete cart item mutation const { mutate: deleteCartItem } = useMutation({ mutationFn: ({ cart_item_id }: { cart_item_id: string }) => cart_api.delete_cart_item(cart_item_id), @@ -130,31 +123,53 @@ const CartPage = () => { ); const subtotal = - cartItems?.reduce((sum, item) => { - if (item.product.prices.length === 0) return sum; - + cartItems.reduce((sum, item) => { + if (!item.product.prices.length) return sum; const maxPrice = Math.max( ...item.product.prices.map((p) => Number(p.price)), ); - - return sum + maxPrice * item.quantity; + return sum + maxPrice * (quantities[item.id] || item.quantity); }, 0) || 0; - const handleQuantityChange = (itemId: string, value: number) => { - setQuantities((prev) => ({ - ...prev, - [itemId]: String(value), - })); + const handleQuantityChange = ( + itemId: number, + delta: number = 0, + newValue?: number, + ) => { + setQuantities((prev) => { + const item = cartItems?.find((i) => i.id === Number(itemId)); + if (!item) return prev; - if (debounceRef.current[itemId]) clearTimeout(debounceRef.current[itemId]!); + const isGram = item.product.meansurement?.name?.toLowerCase() === 'gr'; + const STEP = isGram ? 100 : 1; + const MIN_QTY = isGram ? 100 : 1; - debounceRef.current[itemId] = setTimeout(() => { - if (value <= 0) { - deleteCartItem({ cart_item_id: itemId }); + let updatedQty; + + if (newValue !== undefined) { + // Input'dan kiritilgan qiymat - minimal limitni qo'llash shart emas + updatedQty = newValue; } else { - updateCartItem({ body: { quantity: value }, cart_item_id: itemId }); + // +/- tugmalar bosilganda - STEP qo'llash va minimal limit + updatedQty = (prev[itemId] ?? MIN_QTY) + delta * STEP; + if (updatedQty < MIN_QTY) updatedQty = MIN_QTY; } - }, 500); + + // Debounce server update + if (debounceRef.current[itemId]) + clearTimeout(debounceRef.current[itemId]!); + debounceRef.current[itemId] = setTimeout(() => { + if (updatedQty <= 0) + deleteCartItem({ cart_item_id: itemId.toString() }); + else + updateCartItem({ + body: { quantity: updatedQty }, + cart_item_id: itemId.toString(), + }); + }, 500); + + return { ...prev, [itemId]: updatedQty }; + }); }; return ( @@ -172,13 +187,10 @@ const CartPage = () => { {cartItems.map((item, index) => { const measurementDisplay = item.product.meansurement?.name || 'шт.'; - return (
diff --git a/src/features/product/ui/Product.tsx b/src/features/product/ui/Product.tsx index 08d9500..233f756 100644 --- a/src/features/product/ui/Product.tsx +++ b/src/features/product/ui/Product.tsx @@ -7,28 +7,11 @@ import { useCartId } from '@/shared/hooks/cartId'; import formatDate from '@/shared/lib/formatDate'; import formatPrice from '@/shared/lib/formatPrice'; import { cn } from '@/shared/lib/utils'; -import { Button } from '@/shared/ui/button'; -import { - Carousel, - CarouselApi, - CarouselContent, - CarouselItem, -} from '@/shared/ui/carousel'; import { Input } from '@/shared/ui/input'; import { Skeleton } from '@/shared/ui/skeleton'; -import { ProductCard } from '@/widgets/categories/ui/product-card'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; -import { - ChevronLeft, - ChevronRight, - Heart, - Minus, - Plus, - Shield, - ShoppingCart, - Truck, -} from 'lucide-react'; +import { Heart, Minus, Plus, Shield, ShoppingCart, Truck } from 'lucide-react'; import { useTranslations } from 'next-intl'; import Image from 'next/image'; import { useParams } from 'next/navigation'; @@ -40,27 +23,10 @@ const ProductDetail = () => { const { product } = useParams<{ product: string }>(); const queryClient = useQueryClient(); const { cart_id } = useCartId(); - const [api, setApi] = useState(); - const [canScrollPrev, setCanScrollPrev] = useState(false); - const [canScrollNext, setCanScrollNext] = useState(false); const [quantity, setQuantity] = useState(1); - const [selectedImage, setSelectedImage] = useState(0); const debounceRef = useRef(null); - // O'lchov birligini formatlash uchun yordamchi funksiya - const getQuantityMessage = (qty: number, measurement: string | null) => { - if (!measurement) return `${qty} dona`; - return `${qty} ${measurement}`; - }; - - /* ---------------- CART ITEMS ---------------- */ - const { data: cartItems } = useQuery({ - queryKey: ['cart_items', cart_id], - queryFn: () => cart_api.get_cart_items(cart_id!), - enabled: !!cart_id, - }); - /* ---------------- PRODUCT DETAIL ---------------- */ const { data, isLoading } = useQuery({ queryKey: ['product_detail', product], @@ -69,18 +35,36 @@ const ProductDetail = () => { enabled: !!product, }); - /* ---------------- RECOMMENDATION ---------------- */ - const { data: recomendation, isLoading: recLoad } = useQuery({ - queryKey: ['product_list'], - queryFn: () => product_api.list({ page: 1, page_size: 12 }), - select: (res) => res.data.results, + /* ---------------- CART ITEMS ---------------- */ + const { data: cartItems } = useQuery({ + queryKey: ['cart_items', cart_id], + queryFn: () => cart_api.get_cart_items(cart_id!), + enabled: !!cart_id, }); /* ---------------- DERIVED DATA ---------------- */ const price = Number(data?.prices?.[0]?.price || 0); const maxBalance = data?.balance ?? 0; + + const measurement = data?.meansurement?.name?.toLowerCase() || ''; + const isGram = measurement === 'gr'; + + const STEP = isGram ? 100 : 1; + const MIN_QTY = isGram ? 100 : 1; + const measurementDisplay = data?.meansurement?.name || 'шт.'; + /* ---------------- HELPERS ---------------- */ + const clampQuantity = (value: number) => { + if (!maxBalance) return value; + return Math.min(value, maxBalance); + }; + + const getQuantityMessage = (qty: number, measurement: string | null) => { + if (!measurement) return `${qty} dona`; + return `${qty} ${measurement}`; + }; + /* ---------------- SYNC CART QUANTITY ---------------- */ useEffect(() => { if (!data || !cartItems) return; @@ -89,8 +73,12 @@ const ProductDetail = () => { (i) => Number(i.product.id) === data.id, ); - setQuantity(item ? item.quantity : 1); - }, [data, cartItems]); + if (item) { + setQuantity(item.quantity); + } else { + setQuantity(MIN_QTY); + } + }, [data, cartItems, MIN_QTY]); /* ---------------- DEBOUNCE UPDATE ---------------- */ useEffect(() => { @@ -122,18 +110,20 @@ const ProductDetail = () => { cart_api.cart_item(body), onSuccess: (_, variables) => { queryClient.refetchQueries({ queryKey: ['cart_items'] }); + const measurementName = data?.meansurement?.name || null; + toast.success( - `${getQuantityMessage(variables.quantity, measurementName)} ${t("savatga qo'shildi")}`, - { - richColors: true, - position: 'top-center', - }, + `${getQuantityMessage(variables.quantity, measurementName)} ${t( + "savatga qo'shildi", + )}`, + { richColors: true, position: 'top-center' }, ); }, onError: (err: AxiosError) => { const msg = (err.response?.data as { detail: string })?.detail || err.message; + toast.error(msg, { richColors: true }); }, }); @@ -145,13 +135,15 @@ const ProductDetail = () => { }) => cart_api.update_cart_item(payload), onSuccess: (_, variables) => { queryClient.refetchQueries({ queryKey: ['cart_items'] }); + const measurementName = data?.meansurement?.name || null; + toast.success( - `${t('Miqdor')} ${getQuantityMessage(variables.body.quantity, measurementName)} ${t('ga yangilandi')}`, - { - richColors: true, - position: 'top-center', - }, + `${t('Miqdor')} ${getQuantityMessage( + variables.body.quantity, + measurementName, + )} ${t('ga yangilandi')}`, + { richColors: true, position: 'top-center' }, ); }, }); @@ -161,7 +153,6 @@ const ProductDetail = () => { mutationFn: (id: string) => product_api.favourite(id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['product_detail'] }); - queryClient.invalidateQueries({ queryKey: ['product_list'] }); }, onError: () => { toast.error(t('Tizimga kirilmagan'), { @@ -175,6 +166,8 @@ const ProductDetail = () => { const handleAddToCart = () => { if (!data || !cart_id) return; + const normalizedQty = clampQuantity(quantity); + const cartItem = cartItems?.data.cart_item.find( (i) => Number(i.product.id) === data.id, ); @@ -182,47 +175,35 @@ const ProductDetail = () => { if (cartItem) { updateCartItem({ cart_item_id: cartItem.id.toString(), - body: { quantity }, + body: { quantity: normalizedQty }, }); } else { addToCart({ product: String(data.id), cart: cart_id, - quantity, + quantity: normalizedQty, }); } + + setQuantity(normalizedQty); }; const handleIncrease = () => { - setQuantity((q) => q + 1); + setQuantity((q) => { + let next = q + STEP; + if (isGram) next = Math.ceil(next / STEP) * STEP; + return next; + }); }; const handleDecrease = () => { - setQuantity((q) => Math.max(1, q - 1)); + setQuantity((q) => { + let next = q - STEP; + if (isGram) next = Math.floor(next / STEP) * STEP; + return Math.max(MIN_QTY, next); + }); }; - /* ---------------- CAROUSEL ---------------- */ - useEffect(() => { - if (!api) return; - - const updateButtons = () => { - setCanScrollPrev(api.canScrollPrev()); - setCanScrollNext(api.canScrollNext()); - }; - - updateButtons(); - api.on('select', updateButtons); - api.on('reInit', updateButtons); - - return () => { - api.off('select', updateButtons); - api.off('reInit', updateButtons); - }; - }, [api]); - - const scrollPrev = () => api?.scrollPrev(); - const scrollNext = () => api?.scrollNext(); - /* ---------------- LOADING ---------------- */ if (isLoading) { return ( @@ -236,57 +217,28 @@ const ProductDetail = () => { return (
- {/* IMAGES */} + {/* IMAGE */}
{data?.name - - - {(data?.images?.length - ? data.images - : [{ id: 0, image: '/placeholder.svg' }] - ).map((img, i) => ( - - - - ))} - -
{/* INFO */}

{data?.name}

- {/* Narx va o'lchov birligi */}
{formatPrice(price, true)} @@ -294,13 +246,12 @@ const ProductDetail = () => { /{measurementDisplay}
-

{data?.short_name}

- {/* QUANTITY */}
+
- {/* UPDATED_AT WARNING */} {data?.updated_at && data.payment_type === 'cash' && ( -
+

- {t("Narxi o'zgargan bo'lishi mumkin")} • {t('Yangilangan')}:{' '} + {t("Narxi o'zgargan bo'lishi mumkin")} •{' '} {formatDate.format(data.updated_at, 'DD-MM-YYYY')}

)} -
+

{t('Bepul yetkazib berish')}

@@ -393,56 +328,6 @@ const ProductDetail = () => {
- - {/* RELATED PRODUCTS */} -
- - -

{t("O'xshash mahsulotlar")}

- - - - {recLoad && - Array.from({ length: 6 }).map((_, i) => ( - - - - ))} - - {recomendation - ?.filter((p) => p.state === 'A') - .map((p) => ( - - - - ))} - - - - -
); }; diff --git a/src/shared/config/i18n/messages/ru.json b/src/shared/config/i18n/messages/ru.json index a545628..43a916a 100644 --- a/src/shared/config/i18n/messages/ru.json +++ b/src/shared/config/i18n/messages/ru.json @@ -224,5 +224,6 @@ "Umumiy summa": "Общая сумма", "Qayta buyurtma": "Заказать заново", "Yangilangan": "Обновлено", - "Narxi o'zgargan bo'lishi mumkin": "Цена может быть изменена" + "Narxi o'zgargan bo'lishi mumkin": "Цена может быть изменена", + "ga yangilandi": "обновлено" } diff --git a/src/shared/config/i18n/messages/uz.d.json.ts b/src/shared/config/i18n/messages/uz.d.json.ts index b1bfa08..53020ef 100644 --- a/src/shared/config/i18n/messages/uz.d.json.ts +++ b/src/shared/config/i18n/messages/uz.d.json.ts @@ -224,5 +224,6 @@ declare const messages: { 'Qayta buyurtma': 'Qayta buyurtma'; Yangilangan: 'Yangilangan'; "Narxi o'zgargan bo'lishi mumkin": "Narxi o'zgargan bo'lishi mumkin"; + 'ga yangilandi': 'ga yangilandi'; }; export default messages; diff --git a/src/shared/config/i18n/messages/uz.json b/src/shared/config/i18n/messages/uz.json index 05aca3d..9ce5065 100644 --- a/src/shared/config/i18n/messages/uz.json +++ b/src/shared/config/i18n/messages/uz.json @@ -220,5 +220,6 @@ "Umumiy summa": "Umumiy summa", "Qayta buyurtma": "Qayta buyurtma", "Yangilangan": "Yangilangan", - "Narxi o'zgargan bo'lishi mumkin": "Narxi o'zgargan bo'lishi mumkin" + "Narxi o'zgargan bo'lishi mumkin": "Narxi o'zgargan bo'lishi mumkin", + "ga yangilandi": "ga yangilandi" }