Compare commits

...

18 Commits

Author SHA1 Message Date
Samandar Turgunboyev
9ec9ea586f coomet length max 300 2026-03-11 19:31:28 +05:00
Samandar Turgunboyev
1a2d3eef6c refresh order update 2026-03-10 17:49:36 +05:00
Samandar Turgunboyev
86ceda48f7 refresh order update 2026-03-10 17:46:11 +05:00
Samandar Turgunboyev
68dad90900 update search ui 2026-03-07 10:24:55 +05:00
Samandar Turgunboyev
855600cfe2 update ui 2026-03-07 10:13:03 +05:00
Samandar Turgunboyev
35374094eb bug fixed 2026-03-05 14:57:34 +05:00
Samandar Turgunboyev
95d17b274f add cart no token replace auth login 2026-03-04 16:36:22 +05:00
Samandar Turgunboyev
f04ae13c39 bug fix 2026-03-04 15:54:00 +05:00
Samandar Turgunboyev
3fcbf22b70 update price 2026-03-04 10:14:49 +05:00
Samandar Turgunboyev
a0994df9bd fix vercel config for Next.js 16 2026-03-02 18:42:30 +05:00
Samandar Turgunboyev
fc1bc9c0a9 fix vercel config for Next.js 16 2026-03-02 18:31:49 +05:00
Samandar Turgunboyev
729544cb7e remove vercel legacy config 2026-03-02 18:27:42 +05:00
Samandar Turgunboyev
86f49f6d82 remove vercel legacy config 2026-03-02 18:24:45 +05:00
Samandar Turgunboyev
58ff67ce81 remove vercel legacy config 2026-03-02 18:17:45 +05:00
Samandar Turgunboyev
8248e5aa2a remove vercel legacy config 2026-03-02 18:15:05 +05:00
Samandar Turgunboyev
4f47455233 remove vercel legacy config 2026-03-02 17:55:33 +05:00
Samandar Turgunboyev
ec6e508b1e bug 2026-03-02 17:00:47 +05:00
Samandar Turgunboyev
705ef2e7bc bug fixed 2026-03-02 16:14:06 +05:00
18 changed files with 506 additions and 267 deletions

View File

@@ -25,6 +25,7 @@ import { authForm } from '../lib/form';
const Login = () => {
const router = useRouter();
const t = useTranslations();
const queryClient = useQueryClient();
const form = useForm<z.infer<typeof authForm>>({
resolver: zodResolver(authForm),
defaultValues: {
@@ -32,7 +33,6 @@ const Login = () => {
username: '',
},
});
const queryClient = useQueryClient();
const { mutate, isPending } = useMutation({
mutationFn: (body: {
@@ -42,8 +42,7 @@ const Login = () => {
}) => auth_api.login(body),
onSuccess: (res) => {
router.push('/');
queryClient.refetchQueries({ queryKey: ['all_products'] });
queryClient.refetchQueries({ queryKey: ['list'] });
queryClient.refetchQueries();
setToken(res.data.access);
setRefToken(res.data.refresh);
},

View File

@@ -3,6 +3,6 @@ import { z } from 'zod';
export const orderForm = z.object({
long: z.string().min(1, { message: 'Majburiy maydon' }),
lat: z.string().min(1, { message: 'Majburiy maydon' }),
comment: z.string().min(1, { message: 'Majburiy maydon' }),
comment: z.string().max(300, 'Izoh 300 ta belgidan oshmasligi kerak'),
city: z.string().optional(),
});

View File

@@ -177,10 +177,15 @@ const CartPage = () => {
const subtotal =
cartItems.reduce((sum, item) => {
if (!item.product.prices.length) return sum;
const price = Math.min(
...item.product.prices.map((p) => Number(p.price)),
);
const price = item.product.prices.find((p) => p.price_type.code === '1')
? Number(
item.product.prices.find((p) => p.price_type.code === '1')?.price,
)
: Math.min(...item.product.prices.map((p) => Number(p.price)));
const qty = quantities[item.id] ?? item.quantity;
return sum + price * qty;
}, 0) || 0;
@@ -243,12 +248,25 @@ const CartPage = () => {
<div className="flex items-center gap-2 mb-3">
<span className="text-blue-600 font-bold text-xl">
{formatPrice(
Math.min(
...item.product.prices.map((p) => Number(p.price)),
),
true,
)}
{item.product.prices.find(
(p) => p.price_type.code === '1',
)
? formatPrice(
Number(
item.product.prices.find(
(p) => p.price_type.code === '1',
)?.price,
),
true,
)
: formatPrice(
Math.min(
...item.product.prices.map((p) =>
Number(p.price),
),
),
true,
)}
</span>
<span className="text-sm text-gray-500">
/{measurementDisplay}

View File

@@ -2,6 +2,7 @@
/* eslint-disable */
import LogosProduct from '@/assets/product.png';
import { BASE_URL } from '@/shared/config/api/URLs';
import { useRouter } from '@/shared/config/i18n/navigation';
import { useCartId } from '@/shared/hooks/cartId';
import formatDate from '@/shared/lib/formatDate';
import formatPrice from '@/shared/lib/formatPrice';
@@ -41,6 +42,7 @@ import { uz } from 'date-fns/locale';
import {
Calendar as CalIcon,
CheckCircle2,
ChevronLeft,
Clock,
Loader2,
LocateFixed,
@@ -80,6 +82,7 @@ const OrderPage = () => {
long: '69.240562',
},
});
const router = useRouter();
const [cart, setCart] = useState<number | string | null>(null);
const { cart_id } = useCartId();
const [orderSuccess, setOrderSuccess] = useState(false);
@@ -91,8 +94,6 @@ const OrderPage = () => {
enabled: !!cart,
});
console.log(data);
const { mutate, isPending } = useMutation({
mutationFn: (body: OrderCreateBody) => cart_api.createOrder(body),
onSuccess: (res) => {
@@ -141,11 +142,13 @@ const OrderPage = () => {
if (item.product.prices.length === 0) return sum; // narx yo'q bo'lsa qo'shmaymiz
// Eng yuqori narxni olish
const maxPrice = Math.min(
...item.product.prices.map((p) => Number(p.price)),
);
const maxPrice = item.product.prices.find(
(p) => p.price_type.code === '1',
)
? item.product.prices.find((p) => p.price_type.code === '1')?.price
: Math.min(...item.product.prices.map((p) => Number(p.price)));
return sum + maxPrice * item.quantity;
return sum + Number(maxPrice) * item.quantity;
}, 0) || 0; // cartItems bo'sh bo'lsa 0 qaytaradi
const [coords, setCoords] = useState({
@@ -342,6 +345,15 @@ const OrderPage = () => {
<div className="custom-container mb-5">
<div>
<div className="mb-6">
<Button
variant="outline"
onClick={() => router.back()}
className="mb-4 w-fit px-2"
size={'icon'}
>
<ChevronLeft size={32} />
<p>{t('Orqaga')}</p>
</Button>
<h1 className="text-3xl font-bold text-gray-800 mb-2">
{t('Buyurtmani rasmiylashtirish')}
</h1>
@@ -364,10 +376,14 @@ const OrderPage = () => {
<FormControl>
<Textarea
{...field}
maxLength={300}
className="w-full min-h-42 max-h-64 border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
placeholder={t('Izoh')}
/>
</FormControl>
<div className="text-right text-xs text-gray-500">
{field.value?.length || 0}/300
</div>
<FormMessage />
</FormItem>
)}

View File

@@ -7,19 +7,13 @@ import { Card } from '@/shared/ui/card';
import { Skeleton } from '@/shared/ui/skeleton';
import { ProductCard } from '@/widgets/categories/ui/product-card';
import { useQuery } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { Heart } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useEffect } from 'react';
export default function Favourite() {
const router = useRouter();
const t = useTranslations();
const {
data: favourite,
isLoading,
error,
} = useQuery({
const { data: favourite, isLoading } = useQuery({
queryKey: ['favourite_product'],
queryFn: () => product_api.favouuriteProduct(),
select(data) {
@@ -27,14 +21,6 @@ export default function Favourite() {
},
});
useEffect(() => {
if ((error as AxiosError)?.status === 403) {
router.replace('/auth');
} else if ((error as AxiosError)?.status === 401) {
router.replace('/auth');
}
}, [error]);
if (favourite && favourite.results.length === 0) {
return (
<div className="min-h-screen py-12">

View File

@@ -3,11 +3,13 @@
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 { useRouter } from '@/shared/config/i18n/navigation';
import { useCartId } from '@/shared/hooks/cartId';
import formatPrice from '@/shared/lib/formatPrice';
import { cn } from '@/shared/lib/utils';
import { Input } from '@/shared/ui/input';
import { Skeleton } from '@/shared/ui/skeleton';
import { userStore } from '@/widgets/welcome/lib/hook';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { Heart, Minus, Plus, Shield, ShoppingCart, Truck } from 'lucide-react';
@@ -22,11 +24,17 @@ const ProductDetail = () => {
const { product } = useParams<{ product: string }>();
const queryClient = useQueryClient();
const { cart_id } = useCartId();
const { user } = userStore();
const router = useRouter();
/** ✅ number | string */
const [quantity, setQuantity] = useState<number | string>(1);
// ✅ debounce ref
const debounceRef = useRef<NodeJS.Timeout | null>(null);
// ✅ Flag: faqat manual input (klaviatura) da debounce ishlaydi
const isManualInputRef = useRef(false);
/* ---------------- PRODUCT DETAIL ---------------- */
const { data, isLoading } = useQuery({
queryKey: ['product_detail', product],
@@ -42,7 +50,22 @@ const ProductDetail = () => {
enabled: !!cart_id,
});
const maxBalance = data?.balance ?? 0;
const favouriteMutation = useMutation({
mutationFn: (productId: string) => product_api.favourite(productId),
onSuccess: () => {
queryClient.refetchQueries({ queryKey: ['product_detail'] });
queryClient.refetchQueries({ queryKey: ['favourite_product'] });
},
onError: (err: AxiosError) => {
const detail = (err.response?.data as { detail?: string })?.detail;
toast.error(detail || err.message, {
richColors: true,
position: 'top-center',
});
},
});
const measurement = data?.meansurement?.name?.toLowerCase() || '';
const isGram = measurement === 'gr';
@@ -52,24 +75,30 @@ const ProductDetail = () => {
const measurementDisplay = data?.meansurement?.name || 'шт.';
/** Safe numeric */
/** Safe numeric value */
const numericQty =
quantity === '' || quantity === '.' || quantity === ','
? 0
: Number(String(quantity).replace(',', '.'));
/* ---------------- HELPERS ---------------- */
const clampQuantity = (value: number) => {
if (!maxBalance) return value;
return Math.min(value, maxBalance);
};
if (isNaN(value)) return MIN_QTY;
let safe = value;
if (isGram) {
safe = Math.max(value, MIN_QTY);
safe = Math.ceil(safe / STEP) * STEP;
}
return safe;
};
const getQuantityMessage = (qty: number, measurement: string | null) => {
if (!measurement) return `${qty} dona`;
return `${qty} ${measurement}`;
};
/* ---------------- SYNC CART ---------------- */
/* ---------------- SYNC CART (boshlang'ich holat) ---------------- */
useEffect(() => {
if (!data || !cartItems) return;
@@ -82,35 +111,11 @@ const ProductDetail = () => {
} else {
setQuantity(MIN_QTY);
}
// isManualInputRef ni reset qilish shart - bu sync, manual input emas
isManualInputRef.current = false;
}, [data, cartItems, MIN_QTY]);
/* ---------------- DEBOUNCE UPDATE ---------------- */
useEffect(() => {
if (!cart_id || !data || !cartItems) return;
const cartItem = cartItems.data.cart_item.find(
(i) => Number(i.product.id) === data.id,
);
if (!cartItem || cartItem.quantity === numericQty) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (numericQty > 0) {
updateCartItem({
cart_item_id: cartItem.id.toString(),
body: { quantity: numericQty },
});
}
}, 500);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [numericQty]);
/* ---------------- ADD ---------------- */
/* ---------------- MUTATIONS ---------------- */
const { mutate: addToCart } = useMutation({
mutationFn: (body: { product: string; quantity: number; cart: string }) =>
cart_api.cart_item(body),
@@ -121,10 +126,7 @@ const ProductDetail = () => {
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' },
);
},
@@ -132,7 +134,6 @@ const ProductDetail = () => {
onError: (err: AxiosError) => {
const msg =
(err.response?.data as { detail: string })?.detail || err.message;
toast.error(msg, { richColors: true });
},
});
@@ -149,17 +150,50 @@ const ProductDetail = () => {
const measurementName = data?.meansurement?.name || null;
toast.success(
`${t('Miqdor')} ${getQuantityMessage(
variables.body.quantity,
measurementName,
)} ${t('ga yangilandi')}`,
`${t('Miqdor')} ${getQuantityMessage(variables.body.quantity, measurementName)} ${t('ga yangilandi')}`,
{ richColors: true, position: 'top-center' },
);
},
});
/* ---------------- DEBOUNCE UPDATE (faqat manual input uchun) ---------------- */
useEffect(() => {
// ✅ Faqat klaviatura orqali yozilganda ishlaydi
if (!isManualInputRef.current) 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 === numericQty) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (numericQty >= MIN_QTY) {
updateCartItem({
cart_item_id: cartItem.id.toString(),
body: { quantity: numericQty },
});
}
isManualInputRef.current = false;
}, 500);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [numericQty]);
/* ---------------- HANDLERS ---------------- */
const handleAddToCart = () => {
if (user === null) {
router.push('/auth');
return;
}
if (debounceRef.current) clearTimeout(debounceRef.current);
isManualInputRef.current = false;
if (!data || !cart_id) {
toast.error(t('Tizimga kirilmagan'), {
richColors: true,
@@ -186,34 +220,37 @@ const ProductDetail = () => {
quantity: normalizedQty,
});
}
setQuantity(normalizedQty);
};
const handleIncrease = () => {
// ✅ Bu manual input emas - flag ni false qoldirish
isManualInputRef.current = false;
setQuantity((q) => {
const base = q === '' || q === '.' || q === ',' ? 0 : Number(q);
let next = base + STEP;
if (isGram) next = Math.ceil(next / STEP) * STEP;
return next;
});
};
const handleDecrease = () => {
// ✅ Bu manual input emas - flag ni false qoldirish
isManualInputRef.current = false;
setQuantity((q) => {
const base = q === '' || q === '.' || q === ',' ? MIN_QTY : Number(q);
let next = base - STEP;
if (isGram) next = Math.floor(next / STEP) * STEP;
return Math.max(next, MIN_QTY);
});
};
const subtotal = data?.prices?.length
? Math.min(...data.prices.map((p) => Number(p.price)))
? data.prices.find((p) => p.price_type.code === '1')
? data.prices.find((p) => p.price_type.code === '1')?.price
: Math.min(...data.prices.map((p) => Number(p.price)))
: 0;
/* ---------------- LOADING ---------------- */
@@ -250,12 +287,11 @@ const ProductDetail = () => {
<div className="flex items-baseline gap-2 mb-4">
<span className="text-4xl font-bold text-blue-600">
{formatPrice(subtotal, true)}
{formatPrice(Number(subtotal), true)}
</span>
<span className="text-xl text-gray-500">/{measurementDisplay}</span>
</div>
{/* ✅ INPUT FIXED */}
<div className="mb-6">
<label className="block text-sm font-medium mb-2">
{t('Miqdor')}
@@ -276,9 +312,10 @@ const ProductDetail = () => {
onChange={(e) => {
const v = e.target.value;
/** ✅ allow 0 + decimal + comma */
if (!/^\d*([.,]\d*)?$/.test(v)) return;
// ✅ Faqat shu yerda manual input flag yoqiladi
isManualInputRef.current = true;
setQuantity(v);
}}
className="w-24 text-center"
@@ -298,7 +335,7 @@ const ProductDetail = () => {
</div>
<div className="mb-6 text-xl font-semibold">
{t('Jami')}: {formatPrice(subtotal * numericQty, true)}
{t('Jami')}: {formatPrice(Number(subtotal) * numericQty, true)}
</div>
<div className="flex gap-3">
@@ -312,9 +349,18 @@ const ProductDetail = () => {
<button
className={cn(
'p-3 rounded-lg border',
'p-3 rounded-lg border cursor-pointer',
data?.liked ? 'border-red-500 bg-red-50' : 'border-gray-300',
)}
onClick={(e) => {
e.stopPropagation();
if (user === null) {
router.push('/auth');
return;
} else {
favouriteMutation.mutate(String(data?.id));
}
}}
>
<Heart
className={data?.liked ? 'fill-red-500 text-red-500' : ''}

View File

@@ -7,54 +7,56 @@ export interface OrderList {
user: number;
comment: string;
delivery_date: string;
items: {
items: OrderItem[];
}
export default interface OrderItem {
id: number;
quantity: number;
price: string;
product: {
id: number;
quantity: number;
price: string;
product: {
images: { id: number; images: string | null }[];
liked: false;
meansurement: null | string;
inventory_id: null | string;
product_id: string;
code: string;
name: string;
short_name: string;
weight_netto: null | number;
weight_brutto: null | number;
litr: null | number;
box_type_code: null | number;
box_quant: null | number;
groups: {
id: number;
images: { id: number; images: string | null }[];
liked: false;
meansurement: null | string;
inventory_id: null | string;
product_id: string;
code: string;
name: string;
short_name: string;
weight_netto: null | number;
weight_brutto: null | number;
litr: null | number;
box_type_code: null | number;
box_quant: null | number;
groups: {
}[];
state: 'A' | 'P';
barcodes: string;
article_code: null | string;
marketing_group_code: null | string;
inventory_kinds: {
id: number;
name: string;
}[];
sector_codes: [];
prices: {
id: number;
price: string;
price_type: {
id: number;
name: string;
}[];
state: 'A' | 'P';
barcodes: string;
article_code: null | string;
marketing_group_code: null | string;
inventory_kinds: {
id: number;
name: string;
}[];
code: string;
};
}[];
sector_codes: [];
prices: {
id: number;
price: string;
price_type: {
id: number;
name: string;
code: string;
};
}[];
payment_type: null | string;
balance: number;
updated_at: string;
};
}[];
payment_type: null | string;
balance: number;
updated_at: string;
};
}
export interface OrderListRes {

View File

@@ -6,6 +6,7 @@ import { removeRefToken, removeToken } from '@/shared/lib/token';
import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/avatar';
import { Button } from '@/shared/ui/button';
import { banner_api } from '@/widgets/welcome/lib/api';
import { userStore } from '@/widgets/welcome/lib/hook';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Headset, Home, LogOut } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -17,6 +18,7 @@ const Profile = () => {
const router = useRouter();
const t = useTranslations();
const queryClient = useQueryClient();
const { setUser } = userStore();
const { data: me, isError } = useQuery({
queryKey: ['get_me'],
@@ -119,6 +121,7 @@ const Profile = () => {
removeToken();
removeRefToken();
setCartId(null);
setUser(null);
queryClient.refetchQueries();
}}
className="w-full justify-start gap-3 text-red-500 hover:text-red-600 hover:bg-red-50 mt-4"

View File

@@ -26,6 +26,7 @@ import {
SelectValue,
} from '@/shared/ui/select';
import { Textarea } from '@/shared/ui/textarea';
import { userStore } from '@/widgets/welcome/lib/hook';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Map,
@@ -42,18 +43,21 @@ import {
Loader2,
LocateFixed,
MapPin,
Minus,
Package,
Plus,
ShoppingBag,
Trash2,
User,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import Image from 'next/image';
import { useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import z from 'zod';
import { order_api } from '../lib/api';
import OrderItem, { order_api } from '../lib/api';
const deliveryTimeSlots = [
{ id: 1, label: '10:00 - 12:00', start: '10:00', end: '12:00' },
@@ -61,6 +65,7 @@ const deliveryTimeSlots = [
{ id: 3, label: '14:00 - 16:00', start: '14:00', end: '16:00' },
{ id: 4, label: '16:00 - 18:00', start: '16:00', end: '18:00' },
];
interface CoordsData {
lat: number;
lon: number;
@@ -70,9 +75,16 @@ interface CoordsData {
const RefreshOrder = () => {
const [deliveryDate, setDeliveryDate] = useState<Date>();
const [selectedTimeSlot, setSelectedTimeSlot] = useState<string>('');
const [orderItems, setOrderItems] = useState<OrderItem[]>([]);
const { user } = userStore();
const [quantityInputs, setQuantityInputs] = useState<Record<number, string>>(
{},
);
const t = useTranslations();
const queryClient = useQueryClient();
const searchParams = useSearchParams();
const router = useRouter();
const id = searchParams.get('id');
const { data, isLoading } = useQuery({
@@ -83,6 +95,18 @@ const RefreshOrder = () => {
const initialValues = data?.find((e) => e.id === Number(id));
useEffect(() => {
if (initialValues?.items) {
const items = initialValues.items.map((item: OrderItem) => ({ ...item }));
setOrderItems(items);
const inputs: Record<number, string> = {};
items.forEach((item: OrderItem) => {
inputs[item.id] = String(item.quantity);
});
setQuantityInputs(inputs);
}
}, [initialValues]);
const form = useForm<z.infer<typeof orderForm>>({
resolver: zodResolver(orderForm),
defaultValues: {
@@ -92,7 +116,6 @@ const RefreshOrder = () => {
},
});
// Update form when initialValues loads
useEffect(() => {
if (initialValues?.comment) {
form.setValue('comment', initialValues.comment);
@@ -125,6 +148,66 @@ const RefreshOrder = () => {
[number, number][][] | null
>(null);
// Input o'zgarishi — foydalanuvchi "0.5", "1.75" yoza oladi
const handleQuantityInput = (itemId: number, value: string) => {
// Faqat raqam va nuqtaga ruxsat
if (!/^(\d+\.?\d*)?$/.test(value)) return;
setQuantityInputs((prev) => ({ ...prev, [itemId]: value }));
const parsed = parseFloat(value);
if (!isNaN(parsed) && parsed > 0) {
setOrderItems((prev) =>
prev.map((item) =>
item.id === itemId ? { ...item, quantity: parsed } : item,
),
);
}
};
// Blur — bo'sh yoki 0 qiymatni 1 ga qaytarish
const handleQuantityBlur = (itemId: number) => {
const val = parseFloat(quantityInputs[itemId]);
if (!val || val <= 0) {
setQuantityInputs((prev) => ({ ...prev, [itemId]: '1' }));
setOrderItems((prev) =>
prev.map((item) =>
item.id === itemId ? { ...item, quantity: 1 } : item,
),
);
}
};
// ± tugmalar — 0.5 qadamda o'zgartirish
const updateQuantity = (itemId: number, delta: number) => {
setOrderItems((prev) =>
prev.map((item) => {
if (item.id !== itemId) return item;
const newQty = Math.max(
0.5,
Math.round((item.quantity + delta) * 100) / 100,
);
setQuantityInputs((inputs) => ({
...inputs,
[itemId]: String(newQty),
}));
return { ...item, quantity: newQty };
}),
);
};
// Item o'chirish — agar 0 ta qolsa profile sahifaga yo'naltiradi
const deleteItem = (itemId: number) => {
const updated = orderItems.filter((item) => item.id !== itemId);
setOrderItems(updated);
if (updated.length === 0) {
toast.info(t('Buyurtmada mahsulot qolmadi'), {
richColors: true,
position: 'top-center',
});
router.push('/profile');
}
};
const getCoords = async (name: string): Promise<CoordsData | null> => {
const res = await fetch(
`https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(
@@ -186,11 +269,7 @@ const RefreshOrder = () => {
const timeout = setTimeout(async () => {
const result = await getCoords(cityValue);
if (!result) return;
setCoords({
latitude: result.lat,
longitude: result.lon,
zoom: 12,
});
setCoords({ latitude: result.lat, longitude: result.lon, zoom: 12 });
setPolygonCoords(result.polygon);
form.setValue('lat', result.lat.toString(), { shouldDirty: true });
form.setValue('long', result.lon.toString(), { shouldDirty: true });
@@ -223,7 +302,7 @@ const RefreshOrder = () => {
return;
}
const order_products = initialValues.items
const order_products = orderItems
.filter(
(item) =>
item.product.prices &&
@@ -237,42 +316,44 @@ const RefreshOrder = () => {
on_balance: 'Y',
order_quant: item.quantity,
price_type_code: item.product.prices![0].price_type.code,
product_price: item.product.prices![0].price,
warehouse_code: 'wh1',
product_price: item.price,
warehouse_code: process.env.NEXT_PUBLIC_WARHOUSES_CODE!,
}));
if (user) {
const dealTime = formatDate.format(deliveryDate, 'DD.MM.YYYY');
mutate({
order: [
{
filial_code: 'dodge',
delivery_date: formatDate.format(deliveryDate, 'DD.MM.YYYY'),
room_code: '100',
deal_time:
formatDate.format(deliveryDate, 'DD.MM.YYYY') +
' ' +
selectedTimeSlot,
robot_code: 'r2',
status: 'B#N',
sales_manager_code: '1',
person_code: '12345678',
currency_code: '860',
owner_person_code: '1234567',
note: value.comment,
order_products: order_products,
},
],
});
mutate({
order: [
{
filial_code: process.env.NEXT_PUBLIC_FILIAL_CODE!,
delivery_date: `${dealTime}`,
room_code: process.env.NEXT_PUBLIC_ROOM_CODE!,
deal_time: formatDate.format(new Date(), 'DD.MM.YYYY'),
robot_code: process.env.NEXT_PUBLIC_ROBOT_CODE!,
status: 'D',
sales_manager_code: process.env.NEXT_PUBLIC_SALES_MANAGER_CODE!,
person_code: user?.username,
currency_code: '860',
owner_person_code: user?.username,
note: value.comment,
order_products: order_products,
},
],
});
} else {
toast.error(t('Xatolik yuz berdi'), {
richColors: true,
position: 'top-center',
});
}
};
// Calculate total price
const totalPrice =
initialValues?.items.reduce(
(sum, item) => sum + Number(item.price) * item.quantity,
0,
) || 0;
const totalPrice = orderItems.reduce(
(sum, item) => sum + Number(item.price) * item.quantity,
0,
);
const totalItems =
initialValues?.items.reduce((sum, item) => sum + item.quantity, 0) || 0;
const totalItems = orderItems.reduce((sum, item) => sum + item.quantity, 0);
if (isLoading) {
return (
@@ -361,10 +442,14 @@ const RefreshOrder = () => {
<FormControl>
<Textarea
{...field}
maxLength={300}
className="w-full min-h-42 max-h-64 border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:border-blue-500"
placeholder={t('Izoh')}
/>
</FormControl>
<div className="text-right text-xs text-gray-500">
{field.value?.length || 0}/300
</div>
<FormMessage />
</FormItem>
)}
@@ -558,7 +643,7 @@ const RefreshOrder = () => {
{/* Cart Items */}
<div className="space-y-3 mb-4 max-h-96 overflow-y-auto">
{initialValues.items.map((item) => {
{orderItems.map((item) => {
const productImage = item.product.images?.[0]?.images
? item.product.images[0].images.includes(BASE_URL)
? item.product.images[0].images
@@ -581,19 +666,54 @@ const RefreshOrder = () => {
/>
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-gray-900 truncate">
{item.product.name}
</h4>
<p className="text-xs text-gray-500 mt-1">
{item.quantity} ×{' '}
{formatPrice(Number(item.price), true)}
</p>
<div className="flex items-start justify-between gap-1">
<h4 className="font-semibold text-sm text-gray-900 truncate">
{item.product.name}
</h4>
{/* O'chirish tugmasi */}
<button
type="button"
onClick={() => deleteItem(item.id)}
className="flex-shrink-0 w-6 h-6 flex items-center justify-center rounded-full text-red-400 hover:text-red-600 hover:bg-red-50 transition"
title={t("O'chirish")}
>
<Trash2 className="w-8 h-8" />
</button>
</div>
<p className="text-sm font-bold text-blue-600 mt-1">
{formatPrice(
Number(item.price) * item.quantity,
true,
)}
</p>
{/* Quantity — input + ± tugmalar */}
<div className="flex items-center gap-1 mt-2">
<button
type="button"
onClick={() => updateQuantity(item.id, -1)}
className="w-7 h-7 max-lg:w-10 max-lg:h-10 flex items-center justify-center rounded-full border border-gray-300 bg-white hover:bg-gray-100 transition"
>
<Minus className="w-3 h-3 max-lg:w-4 max-lg:h-4" />
</button>
<input
type="text"
inputMode="decimal"
value={quantityInputs[item.id] ?? item.quantity}
onChange={(e) =>
handleQuantityInput(item.id, e.target.value)
}
onBlur={() => handleQuantityBlur(item.id)}
className="w-14 h-7 max-lg:w-24 max-lg:h-10 text-center text-sm font-semibold border border-gray-300 rounded-md focus:outline-none focus:border-blue-500"
/>
<button
type="button"
onClick={() => updateQuantity(item.id, 1)}
className="w-7 h-7 max-lg:w-10 max-lg:h-10 flex items-center justify-center rounded-full border border-gray-300 bg-white hover:bg-gray-100 transition"
>
<Plus className="w-3 h-3 max-lg:w-4 max-lg:h-4" />
</button>
</div>
</div>
</div>
);

View File

@@ -3,13 +3,14 @@
import { product_api } from '@/shared/config/api/product/api';
import { ProductListResult } from '@/shared/config/api/product/type';
import { useRouter } from '@/shared/config/i18n/navigation';
import { Button } from '@/shared/ui/button';
import { Input } from '@/shared/ui/input';
import { ProductCard } from '@/widgets/categories/ui/product-card';
import { useQuery } from '@tanstack/react-query';
import { Search, X } from 'lucide-react';
import { Search } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useState } from 'react';
const SearchResult = () => {
const router = useRouter();
@@ -19,25 +20,6 @@ const SearchResult = () => {
const query = searchParams.get('search') || '';
const [inputValue, setInputValue] = useState(query);
/* 🔹 URL → Input sync */
useEffect(() => {
setInputValue(query);
}, [query]);
/* 🔹 Debounce → Input → URL sync */
useEffect(() => {
const delay = setTimeout(() => {
if (!inputValue.trim()) {
router.replace('/search');
} else {
router.replace(`/search?search=${encodeURIComponent(inputValue)}`);
}
}, 400);
return () => clearTimeout(delay);
}, [inputValue, router]);
/* 🔹 Default product list */
const { data: productList, isLoading: listLoading } = useQuery({
queryKey: ['product_list'],
queryFn: () => product_api.list({ page: 1, page_size: 12 }),
@@ -46,12 +28,11 @@ const SearchResult = () => {
staleTime: 0,
});
/* 🔹 Search query */
const { data: searchList, isLoading: searchLoading } = useQuery({
queryKey: ['search', query, inputValue],
queryKey: ['search', query],
queryFn: () =>
product_api.search({
search: inputValue, // agar backend `q` kutsa → q: query
search: query,
page: 1,
page_size: 12,
}),
@@ -63,12 +44,6 @@ const SearchResult = () => {
const data = query ? (searchList ?? []) : (productList ?? []);
const isLoading = query ? searchLoading : listLoading;
/* 🔹 Handlers */
const clearSearch = () => {
setInputValue('');
router.replace('/search');
};
return (
<div className="custom-container min-h-screen">
{/* 🔍 Search input */}
@@ -83,14 +58,23 @@ const SearchResult = () => {
className="w-full pl-10 pr-10 h-12"
/>
{inputValue && (
<Button
className="absolute top-0 right-0 h-12 cursor-pointer"
onClick={() =>
router.replace(`/search?search=${encodeURIComponent(inputValue)}`)
}
>
{t('Qidirish')}
</Button>
{/* {inputValue && (
<button
onClick={clearSearch}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X className="w-5 h-5" />
</button>
)}
)} */}
</div>
</div>

View File

@@ -40,6 +40,7 @@ export interface ProductListResult {
price_type: {
id: number;
name: string;
code: string;
};
}[];
balance: number;
@@ -76,6 +77,7 @@ export interface ProductDetail {
price_type: {
id: number;
name: string;
code: string;
};
}[];
}
@@ -131,6 +133,7 @@ export interface FavouriteProductRes {
price_type: {
id: number;
name: string;
code: string;
};
}[];
}

View File

@@ -3,12 +3,14 @@
import { useRouter } from '@/shared/config/i18n/navigation';
import { cn } from '@/shared/lib/utils';
import { Button } from '@/shared/ui/button';
import { Card } from '@/shared/ui/card';
import {
Carousel,
CarouselContent,
CarouselItem,
type CarouselApi,
} from '@/shared/ui/carousel';
import { Skeleton } from '@/shared/ui/skeleton';
import { ProductCard } from '@/widgets/categories/ui/product-card';
import { ProductRes } from '@/widgets/welcome/lib/api';
import { ChevronLeft, ChevronRight } from 'lucide-react';
@@ -20,11 +22,13 @@ import { memo, useEffect, useRef, useState } from 'react';
interface CategoryCarouselProps {
category: ProductRes;
isLoading: boolean;
isError: boolean;
}
const CategoryCarousel = memo(function CategoryCarousel({
category,
isLoading,
isError,
}: CategoryCarouselProps) {
const router = useRouter();
const [api, setApi] = useState<CarouselApi>();
@@ -36,7 +40,6 @@ const CategoryCarousel = memo(function CategoryCarousel({
// Intersection Observer
useEffect(() => {
if (!sectionRef.current) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
@@ -48,7 +51,6 @@ const CategoryCarousel = memo(function CategoryCarousel({
},
{ rootMargin: '100px', threshold: 0.1 },
);
observer.observe(sectionRef.current);
return () => observer.disconnect();
}, []);
@@ -72,7 +74,6 @@ const CategoryCarousel = memo(function CategoryCarousel({
const scrollPrev = () => api?.scrollPrev();
const scrollNext = () => api?.scrollNext();
// Shartli renderlar
if (!isVisible) {
return (
<section
@@ -82,11 +83,8 @@ const CategoryCarousel = memo(function CategoryCarousel({
);
}
if (!isLoading && !category) return null;
const activeProducts =
category?.products.filter((p) => p.state === 'A') ?? [];
if (!isLoading && activeProducts.length === 0) return null;
category?.products?.filter((p) => p.state === 'A') ?? [];
return (
<section
@@ -99,7 +97,7 @@ const CategoryCarousel = memo(function CategoryCarousel({
onClick={() => router.push(`/category/${category.id}/`)}
>
<h2 className="text-2xl font-bold text-slate-800 group-hover:text-blue-600 transition-colors">
{category.name}
{category.name || '---'}
</h2>
<div className="p-1.5 bg-slate-100 rounded-full group-hover:bg-blue-100 transition-all">
<ChevronRight className="text-slate-600 group-hover:text-blue-600 group-hover:translate-x-0.5 transition-all" />
@@ -108,22 +106,33 @@ const CategoryCarousel = memo(function CategoryCarousel({
</div>
<Carousel
opts={{
align: 'start',
dragFree: true, // 🔥 free scroll
}}
opts={{ align: 'start', dragFree: true }}
className="w-full mt-2"
setApi={setApi}
>
<CarouselContent className="pr-[12%] sm:pr-0">
{activeProducts.map((product) => (
<CarouselItem
key={product.id}
className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2"
>
<ProductCard product={product} />
</CarouselItem>
))}
{isLoading || isError || activeProducts.length === 0
? Array.from({ length: 6 }).map((__, index) => (
<CarouselItem
key={index}
className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2"
>
<Card className="p-3 space-y-3 rounded-xl">
<Skeleton className="h-40 sm:h-48 md:h-56 w-full rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-10 w-full rounded-lg" />
</Card>
</CarouselItem>
))
: activeProducts.map((product) => (
<CarouselItem
key={product.id}
className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2"
>
<ProductCard product={product} />
</CarouselItem>
))}
</CarouselContent>
</Carousel>

View File

@@ -16,6 +16,7 @@ import { Button } from '@/shared/ui/button';
import { Card, CardContent } from '@/shared/ui/card';
import { Input } from '@/shared/ui/input';
import { FlyingAnimationPortal } from '@/widgets/animation/FlyingAnimationPortal';
import { userStore } from '@/widgets/welcome/lib/hook';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';
import { Heart, Minus, Plus } from 'lucide-react';
@@ -41,6 +42,7 @@ export function ProductCard({
const [animated, setAnimated] = useState(false);
const debounceRef = useRef<NodeJS.Timeout | null>(null);
const imageRef = useRef<HTMLDivElement>(null);
const { user } = userStore();
/** ✅ Measurement */
const measurementName = product.meansurement?.name ?? null;
@@ -67,7 +69,6 @@ export function ProductCard({
onError: (err: AxiosError) => {
const detail = (err.response?.data as { detail?: string })?.detail;
toast.error(detail || err.message, {
richColors: true,
position: 'top-center',
@@ -176,14 +177,19 @@ export function ProductCard({
queryClient.refetchQueries({ queryKey: ['favourite_product'] });
},
onError: () => {
toast.error(t('Tizimga kirilmagan'), {
onError: (err: AxiosError) => {
const detail = (err.response?.data as { detail?: string })?.detail;
toast.error(detail || err.message, {
richColors: true,
position: 'top-center',
});
},
});
const price = product.prices.find((p) => p.price_type.code === '1')
? product.prices.find((p) => p.price_type.code === '1')?.price
: Math.min(...product.prices.map((p) => Number(p.price)));
/** ❌ Error */
if (error) {
return (
@@ -211,7 +217,12 @@ export function ProductCard({
<Button
onClick={(e) => {
e.stopPropagation();
favouriteMutation.mutate(String(product.id));
if (user === null) {
router.push('/auth');
return;
} else {
favouriteMutation.mutate(String(product.id));
}
}}
className="absolute top-2 right-2 z-10 bg-white hover:bg-gray-200 rounded-full p-2 shadow"
>
@@ -242,10 +253,7 @@ export function ProductCard({
<div className="p-3 sm:p-4 space-y-2 flex-1">
{product.prices.length > 0 && (
<p className="text-lg font-bold">
{formatPrice(
Math.min(...product.prices.map((p) => Number(p.price))),
true,
)}
{formatPrice(Number(price), true)}
<span className="text-sm text-slate-500 ml-1">
/{measurementDisplay}
</span>
@@ -260,11 +268,16 @@ export function ProductCard({
<Button
onClick={(e) => {
e.stopPropagation();
addToCart({
product: String(product.id),
quantity: defaultQty,
cart: cart_id!,
});
if (user) {
addToCart({
product: String(product.id),
quantity: defaultQty,
cart: cart_id!,
});
} else {
router.push('/auth');
}
}}
className="w-full bg-white border border-slate-300 text-slate-700"
>
@@ -313,9 +326,6 @@ export function ProductCard({
}, 500);
}}
/>
<span className="text-xs text-slate-500">
{measurementDisplay}
</span>
</div>
<Button size="icon" variant="ghost" onClick={increase}>

View File

@@ -48,7 +48,7 @@ const NavbarMobile = () => {
useEffect(() => {
if (cartItems) {
const total = cartItems.reduce((sum, item) => sum + item.quantity, 0);
const total = cartItems.length;
setCartQuenty(total > 9 ? 9 : total);
}
}, [cartItems]);

View File

@@ -99,7 +99,6 @@ export const SearchResult = ({ query }: SearchResultProps) => {
{list
.filter((item) => item.state === 'A')
.slice(0, 5)
.map((product) => {
const image =
product.images.length > 0
@@ -107,7 +106,9 @@ export const SearchResult = ({ query }: SearchResultProps) => {
? product.images[0].image
: BASE_URL + product.images[0].image
: LogosProduct;
const price = product.prices?.[0]?.price;
const price = product.prices.find((p) => p.price_type.code === '1')
? product.prices.find((p) => p.price_type.code === '1')?.price
: Math.min(...product.prices.map((p) => Number(p.price)));
return (
<div
@@ -128,14 +129,9 @@ export const SearchResult = ({ query }: SearchResultProps) => {
<p className="text-sm font-medium text-slate-900 line-clamp-2">
{product.name}
</p>
{price && (
<p className="text-sm font-semibold text-[#57A595] mt-1">
{formatPrice(
Math.min(...product.prices.map((p) => Number(p.price))),
true,
)}
</p>
)}
<p className="text-sm font-semibold text-[#57A595] mt-1">
{formatPrice(Number(price), true)}
</p>
</div>
</div>
);

View File

@@ -104,7 +104,7 @@ const Navbar = () => {
useEffect(() => {
if (cartItems) {
const total = cartItems.reduce((sum, item) => sum + item.quantity, 0);
const total = cartItems.length;
setCartQuenty(total > 9 ? 9 : total);
} else if (cart_id === null) {
setCartQuenty(0);
@@ -453,7 +453,14 @@ const Navbar = () => {
<Button
variant={'ghost'}
className="h-10 max-lg:hidden cursor-pointer border border-slate-200"
onClick={() => router.push('/favourite')}
onClick={(e) => {
e.stopPropagation();
if (token) {
router.push('/favourite');
return;
}
router.push('/auth');
}}
aria-label="my favouurite product"
>
<Heart className="size-4 text-foreground" />
@@ -461,7 +468,14 @@ const Navbar = () => {
<Button
variant={'ghost'}
id="cart-icon"
onClick={() => router.push('/cart')}
onClick={(e) => {
e.stopPropagation();
if (token) {
router.push('/cart');
return;
}
router.push('/auth');
}}
className="h-10 relative max-lg:hidden cursor-pointer border border-slate-200"
>
<ShoppingCart className="size-4 text-foreground" />

View File

@@ -6,10 +6,10 @@ type State = {
};
type Actions = {
setUser: (qty: UserRes) => void;
setUser: (qty: UserRes | null) => void;
};
export const userStore = create<State & Actions>((set) => ({
user: null,
setUser: (user: UserRes) => set(() => ({ user })),
setUser: (user: UserRes | null) => set(() => ({ user })),
}));

View File

@@ -80,7 +80,11 @@ const Welcome = () => {
},
});
const { data: allProducts, isLoading: allProductsLoading } = useQuery({
const {
data: allProducts,
isLoading: allProductsLoading,
isError: allProductsError,
} = useQuery({
queryKey: ['all_products'],
queryFn: () => banner_api.getAllProducts(),
select(data) {
@@ -300,14 +304,43 @@ const Welcome = () => {
</Button>
</section>
{allProducts &&
allProducts.map((e) => (
{productLoading || allProductsLoading ? (
<section className="relative custom-container mt-5 justify-center items-center border-b border-slate-200">
<Carousel
opts={{
align: 'start',
dragFree: true,
}}
className="w-full"
setApi={setApiPro}
>
<CarouselContent className="pr-[12%] sm:pr-0">
{Array.from({ length: 6 }).map((__, index) => (
<CarouselItem
key={index}
className="basis-1/2 sm:basis-1/3 md:basis-1/4 lg:basis-1/5 xl:basis-1/6 pb-2"
>
<Card className="p-3 space-y-3 rounded-xl">
<Skeleton className="h-40 sm:h-48 md:h-56 w-full rounded-lg" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-10 w-full rounded-lg" />
</Card>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</section>
) : (
allProducts?.map((e) => (
<CategoryCarousel
category={e}
key={e.id}
isLoading={allProductsLoading}
isError={allProductsError}
/>
))}
))
)}
</>
);
};