diff --git a/src/features/cart/ui/CartPage.tsx b/src/features/cart/ui/CartPage.tsx index 35573d5..7e2297c 100644 --- a/src/features/cart/ui/CartPage.tsx +++ b/src/features/cart/ui/CartPage.tsx @@ -1,4 +1,5 @@ 'use client'; + import ProductBanner from '@/assets/product.png'; import { cart_api } from '@/features/cart/lib/api'; import { BASE_URL } from '@/shared/config/api/URLs'; @@ -36,21 +37,28 @@ const CartPage = () => { select: (data) => data.data.cart_item, }); + // Haqiqiy raqamlar (hisob-kitob uchun) const [quantities, setQuantities] = useState>({}); + // Input ko'rinishi uchun string (nuqta bilan yozish imkonini beradi) + const [inputValues, setInputValues] = useState>({}); + const debounceRef = useRef>({}); - // Initial state + // Initial quantities useEffect(() => { if (!cartItems) return; const initialQuantities: Record = {}; + const initialInputValues: Record = {}; cartItems.forEach((item) => { initialQuantities[item.id] = item.quantity; + initialInputValues[item.id] = String(item.quantity); debounceRef.current[item.id] = null; }); setQuantities(initialQuantities); + setInputValues(initialInputValues); }, [cartItems]); - // Update cart item mutation + // Update mutation const { mutate: updateCartItem } = useMutation({ mutationFn: ({ body, @@ -61,14 +69,13 @@ const CartPage = () => { }) => cart_api.update_cart_item({ body, cart_item_id }), onSuccess: (_, variables) => { queryClient.invalidateQueries({ queryKey: ['cart_items', cart_id] }); - const item = cartItems?.find( (i) => String(i.id) === variables.cart_item_id, ); if (item) { - const measurementName = item.product.meansurement?.name || null; + const measurementName = item.product.meansurement?.name || 'шт.'; toast.success( - `${t('Miqdor')} ${variables.body.quantity} ${measurementName || 'шт.'} ${t('ga yangilandi')}`, + `${t('Miqdor')} ${variables.body.quantity} ${measurementName} ${t('ga yangilandi')}`, { richColors: true, position: 'top-center' }, ); } @@ -77,7 +84,7 @@ const CartPage = () => { toast.error(err.message, { richColors: true, position: 'top-center' }), }); - // Delete cart item mutation + // Delete mutation const { mutate: deleteCartItem } = useMutation({ mutationFn: ({ cart_item_id }: { cart_item_id: string }) => cart_api.delete_cart_item(cart_item_id), @@ -94,6 +101,51 @@ const CartPage = () => { const handleCheckout = () => router.push('/cart/order'); + const handleQuantityChange = ( + itemId: number, + delta: number = 0, + newValue?: number, + ) => { + setQuantities((prev) => { + const item = cartItems?.find((i) => i.id === itemId); + if (!item) return prev; + + const isGram = item.product.meansurement?.name?.toLowerCase() === 'gr'; + const STEP = isGram ? 100 : 1; + const MIN_QTY = isGram ? 100 : 0.5; + + let updatedQty: number; + if (newValue !== undefined) { + updatedQty = newValue; + } else { + updatedQty = (prev[itemId] ?? MIN_QTY) + delta * STEP; + if (updatedQty < MIN_QTY) updatedQty = MIN_QTY; + } + + // inputValues ni ham yangilash + setInputValues((prevInput) => ({ + ...prevInput, + [itemId]: String(updatedQty), + })); + + 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 }; + }); + }; + if (isLoading) return (
@@ -125,53 +177,13 @@ const CartPage = () => { const subtotal = cartItems.reduce((sum, item) => { if (!item.product.prices.length) return sum; - const maxPrice = Math.min( + const price = Math.min( ...item.product.prices.map((p) => Number(p.price)), ); - return sum + maxPrice * (quantities[item.id] || item.quantity); + const qty = quantities[item.id] ?? item.quantity; + return sum + price * qty; }, 0) || 0; - const handleQuantityChange = ( - itemId: number, - delta: number = 0, - newValue?: number, - ) => { - setQuantities((prev) => { - const item = cartItems?.find((i) => i.id === Number(itemId)); - if (!item) return prev; - - const isGram = item.product.meansurement?.name?.toLowerCase() === 'gr'; - const STEP = isGram ? 100 : 1; - const MIN_QTY = isGram ? 100 : 1; - - let updatedQty; - - if (newValue !== undefined) { - // Input'dan kiritilgan qiymat - minimal limitni qo'llash shart emas - updatedQty = newValue; - } else { - // +/- tugmalar bosilganda - STEP qo'llash va minimal limit - updatedQty = (prev[itemId] ?? MIN_QTY) + delta * STEP; - if (updatedQty < MIN_QTY) updatedQty = MIN_QTY; - } - - // 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 (
@@ -187,6 +199,10 @@ const CartPage = () => { {cartItems.map((item, index) => { const measurementDisplay = item.product.meansurement?.name || 'шт.'; + const quantity = quantities[item.id] ?? item.quantity; + // Input uchun string qiymat (nuqta bilan yozish imkonini beradi) + const inputValue = inputValues[item.id] ?? String(item.quantity); + return (
{ onClick={() => deleteCartItem({ cart_item_id: String(item.id) }) } - className="absolute right-2 w-7 h-7 top-2 cursor-pointer" + className="absolute right-2 w-7 h-7 top-2" > -
+
0 @@ -213,11 +229,10 @@ const CartPage = () => { : ProductBanner } alt={item.product.name} - width={500} - height={500} + width={300} + height={300} unoptimized - className="object-cover" - style={{ width: '100%', height: '100%' }} + className="object-cover w-full h-full" />
@@ -225,7 +240,8 @@ const CartPage = () => {

{item.product.name}

-
+ +
{formatPrice( Math.min( @@ -240,36 +256,90 @@ const CartPage = () => {

- {t('Miqdor')}: {quantities[item.id]} {measurementDisplay} + {t('Miqdor')}: {quantity} {measurementDisplay}

-
+
{ - const cleaned = e.target.value.replace(/\D/g, ''); - const val = cleaned === '' ? 0 : Number(cleaned); - handleQuantityChange(item.id, 0, val); + const v = e.target.value; + + // Faqat raqamlar va bitta nuqtaga ruxsat (vergul ham) + if (!/^\d*[.,]?\d*$/.test(v)) return; + + // String sifatida saqlash (nuqta yo'qolmasin) + setInputValues((prev) => ({ + ...prev, + [item.id]: v, + })); + + // Bo'sh yoki faqat nuqta bo'lsa raqamga o'tkazmaymiz + if (v === '' || v === '.' || v === ',') return; + + const parsed = parseFloat(v.replace(',', '.')); + if (isNaN(parsed)) return; + + // quantities ni yangilash + setQuantities((prev) => ({ + ...prev, + [item.id]: parsed, + })); + + // Debounce bilan API ga yuborish + if (debounceRef.current[item.id]) + clearTimeout(debounceRef.current[item.id]!); + + debounceRef.current[item.id] = setTimeout(() => { + if (parsed <= 0) { + deleteCartItem({ + cart_item_id: String(item.id), + }); + } else { + updateCartItem({ + body: { quantity: parsed }, + cart_item_id: String(item.id), + }); + } + }, 500); + }} + onBlur={() => { + // Blur bo'lganda noto'g'ri qiymatlarni tuzatish + const isGram = + item.product.meansurement?.name?.toLowerCase() === + 'gr'; + const MIN_QTY = isGram ? 100 : 0.5; + let value = quantities[item.id] ?? item.quantity; + + if (!value || isNaN(value) || value < MIN_QTY) + value = MIN_QTY; + + setInputValues((prev) => ({ + ...prev, + [item.id]: String(value), + })); + handleQuantityChange(item.id, 0, value); }} type="text" - className="w-14 text-center border-none p-0" + inputMode="decimal" + className="w-16 text-center border-none p-0" /> - + {measurementDisplay}
@@ -295,10 +365,8 @@ const CartPage = () => { {t('Yetkazib berish')}: - - - {t('Bepul')} - + + {t('Bepul')}
@@ -322,7 +390,7 @@ const CartPage = () => { diff --git a/src/features/product/ui/Product.tsx b/src/features/product/ui/Product.tsx index 12eae77..925e082 100644 --- a/src/features/product/ui/Product.tsx +++ b/src/features/product/ui/Product.tsx @@ -4,7 +4,6 @@ import { cart_api } from '@/features/cart/lib/api'; import { product_api } from '@/shared/config/api/product/api'; import { BASE_URL } from '@/shared/config/api/URLs'; 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 { Input } from '@/shared/ui/input'; @@ -24,7 +23,8 @@ const ProductDetail = () => { const queryClient = useQueryClient(); const { cart_id } = useCartId(); - const [quantity, setQuantity] = useState(1); + /** ✅ number | string */ + const [quantity, setQuantity] = useState(1); const debounceRef = useRef(null); /* ---------------- PRODUCT DETAIL ---------------- */ @@ -54,6 +54,12 @@ const ProductDetail = () => { const measurementDisplay = data?.meansurement?.name || 'шт.'; + /** ✅ Safe numeric */ + const numericQty = + quantity === '' || quantity === '.' || quantity === ',' + ? 0 + : Number(String(quantity).replace(',', '.')); + /* ---------------- HELPERS ---------------- */ const clampQuantity = (value: number) => { if (!maxBalance) return value; @@ -65,7 +71,7 @@ const ProductDetail = () => { return `${qty} ${measurement}`; }; - /* ---------------- SYNC CART QUANTITY ---------------- */ + /* ---------------- SYNC CART ---------------- */ useEffect(() => { if (!data || !cartItems) return; @@ -82,44 +88,49 @@ const ProductDetail = () => { /* ---------------- DEBOUNCE UPDATE ---------------- */ useEffect(() => { - if (!cart_id || !data || !cartItems || quantity <= 0) return; + if (!cart_id || !data || !cartItems) return; const cartItem = cartItems.data.cart_item.find( (i) => Number(i.product.id) === data.id, ); - if (!cartItem || cartItem.quantity === quantity) return; + if (!cartItem || cartItem.quantity === numericQty) return; if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { - updateCartItem({ - cart_item_id: cartItem.id.toString(), - body: { quantity }, - }); + if (numericQty > 0) { + updateCartItem({ + cart_item_id: cartItem.id.toString(), + body: { quantity: numericQty }, + }); + } }, 500); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; - }, [quantity]); + }, [numericQty]); - /* ---------------- ADD TO CART ---------------- */ + /* ---------------- ADD ---------------- */ const { mutate: addToCart } = useMutation({ mutationFn: (body: { product: string; quantity: number; cart: string }) => 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", - )}`, + `${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; @@ -133,6 +144,7 @@ const ProductDetail = () => { cart_item_id: string; body: { quantity: number }; }) => cart_api.update_cart_item(payload), + onSuccess: (_, variables) => { queryClient.refetchQueries({ queryKey: ['cart_items'] }); @@ -148,25 +160,11 @@ const ProductDetail = () => { }, }); - /* ---------------- FAVOURITE ---------------- */ - const favouriteMutation = useMutation({ - mutationFn: (id: string) => product_api.favourite(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['product_detail'] }); - }, - onError: () => { - toast.error(t('Tizimga kirilmagan'), { - richColors: true, - position: 'top-center', - }); - }, - }); - /* ---------------- HANDLERS ---------------- */ const handleAddToCart = () => { if (!data || !cart_id) return; - const normalizedQty = clampQuantity(quantity); + const normalizedQty = clampQuantity(numericQty); const cartItem = cartItems?.data.cart_item.find( (i) => Number(i.product.id) === data.id, @@ -190,17 +188,23 @@ const ProductDetail = () => { const handleIncrease = () => { setQuantity((q) => { - let next = q + STEP; + const base = q === '' || q === '.' || q === ',' ? 0 : Number(q); + let next = base + STEP; + if (isGram) next = Math.ceil(next / STEP) * STEP; + return next; }); }; const handleDecrease = () => { setQuantity((q) => { - let next = q - STEP; + const base = q === '' || q === '.' || q === ',' ? 0 : Number(q); + let next = base - STEP; + if (isGram) next = Math.floor(next / STEP) * STEP; - return Math.min(MIN_QTY, next); + + return next <= MIN_QTY ? MIN_QTY : next; }); }; @@ -213,11 +217,9 @@ const ProductDetail = () => { ); } - /* ===================== RENDER ===================== */ return (
- {/* IMAGE */}
{ />
- {/* INFO */}

{data?.name}

@@ -246,7 +247,7 @@ const ProductDetail = () => { /{measurementDisplay}
- {/* QUANTITY */} + {/* ✅ INPUT FIXED */}