added debounce to - + buttons

This commit is contained in:
Jelaletdin12
2025-11-16 23:37:21 +05:00
parent f867896817
commit 4fe0fb3d4e
52 changed files with 2548 additions and 2253 deletions

11
Soraglar.txt Normal file
View File

@@ -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.

View File

@@ -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<string>("");
const [selectedProvince, setSelectedProvince] = useState<number | null>(null);
const [note, setNote] = useState<string>("");
const router = useRouter();
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) => {
// 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<string, typeof provinces>);
}, [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<number, { seller: { id: number; name: string }; items: typeof cartItems }>);
}, [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 (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
<p>{t("loading")}</p>
<p>{t("common.loading")}</p>
</div>
);
}
@@ -101,61 +128,20 @@ export default function CartPage() {
return (
<div className="container mx-auto px-4 min-h-[90vh] flex items-center justify-center">
<h2 className="text-3xl md:text-4xl lg:text-5xl text-gray-400 font-semibold">
{t("emptyCart")}
{t("cart_empty")}
</h2>
</div>
);
}
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<number, { seller: any; items: typeof cartItems }>);
const totalAmount = cartItems.reduce((sum, item) => {
const price = parseFloat(item.product.price_amount || "0");
return sum + price * item.product_quantity;
}, 0);
return (
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{translations.cart}</h1>
<h1 className="text-3xl font-bold mb-6">{t("cart")}</h1>
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1">
<Card className="p-6 rounded-xl">
{Object.entries(itemsBySeller).map(
([sellerId, { seller, items }]) => (
{Object.entries(itemsBySeller).map(([sellerId, { seller, items }]) => (
<div key={sellerId} className="mb-6">
<p className="text-base font-semibold mb-3">{seller.name}</p>
<div className="space-y-4">
@@ -188,7 +174,6 @@ export default function CartPage() {
) || [],
},
}}
translations={translations}
/>
);
})}
@@ -197,41 +182,26 @@ export default function CartPage() {
<Separator className="mt-4" />
)}
</div>
)
)}
))}
</Card>
</div>
<OrderSummary
order={{
id: 1,
seller: { id: 1, name: "Store" },
items: cartItems.map((item) => ({
...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}

View File

@@ -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<number | null>(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]);
if (isLoading) {
return (
const loadingSkeleton = useMemo(() => (
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="w-full h-64 rounded-lg" />
))}
</div>
</div>
);
), [t]);
if (isLoading) {
return loadingSkeleton;
}
if (isError || !favorites || favorites.length === 0) {
return (
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-2xl text-gray-400">{t.emptyFavorites}</p>
<p className="text-2xl text-gray-400">{t("empty_favorites")}</p>
</div>
</div>
);
@@ -96,7 +91,7 @@ export default function FavoritesPage() {
return (
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.favorites}</h1>
<h1 className="text-3xl font-bold mb-6">{t("favorite_products")}</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{favorites.map((favorite: Favorite) => (
<ProductCard
@@ -109,7 +104,6 @@ export default function FavoritesPage() {
isHovered={isHovered === favorite.product.id}
isRemoving={isRemoving}
isAddingToCart={isAddingToCart}
translations={t}
/>
))}
</div>
@@ -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;
@@ -208,7 +197,7 @@ function ProductCard({
{product.stock === 0 && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<Badge variant="secondary" className="text-sm">
Нет в наличии
{t("out_of_stock")}
</Badge>
</div>
)}
@@ -241,7 +230,7 @@ function ProductCard({
size="sm"
>
<ShoppingCart className="h-4 w-4" />
{translations.addToCart}
{t("add_to_cart")}
</Button>
</div>
)}

View File

@@ -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<FormData>({
firstName: "",
lastName: "",
email: "",
phone: "+993",
file: null,
})
const [errors, setErrors] = useState<FormErrors>({})
const [fileName, setFileName] = useState("")
// export default function OpenStorePage({ locale = "ru", translations }: OpenStorePageProps) {
// const [formData, setFormData] = useState<FormData>({
// firstName: "",
// lastName: "",
// email: "",
// phone: "+993",
// file: null,
// })
// const [errors, setErrors] = useState<FormErrors>({})
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
// 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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md shadow-lg">
<CardHeader>
<CardTitle className="text-2xl text-center">{t.title}</CardTitle>
<CardDescription className="text-center">Заполните форму для подачи заявления</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="firstName">{t.firstName}</Label>
<Input
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className={errors.firstName ? "border-red-500" : ""}
/>
{errors.firstName && <p className="text-sm text-red-500">{errors.firstName}</p>}
</div>
// return (
// <div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
// <Card className="w-full max-w-md shadow-lg">
// <CardHeader>
// <CardTitle className="text-2xl text-center">{t.title}</CardTitle>
// <CardDescription className="text-center">Заполните форму для подачи заявления</CardDescription>
// </CardHeader>
// <CardContent>
// <form onSubmit={handleSubmit} className="space-y-4">
// {/* First Name */}
// <div className="space-y-2">
// <Label htmlFor="firstName">{t.firstName}</Label>
// <Input
// id="firstName"
// name="firstName"
// value={formData.firstName}
// onChange={handleInputChange}
// className={errors.firstName ? "border-red-500" : ""}
// />
// {errors.firstName && <p className="text-sm text-red-500">{errors.firstName}</p>}
// </div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="lastName">{t.lastName}</Label>
<Input
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className={errors.lastName ? "border-red-500" : ""}
/>
{errors.lastName && <p className="text-sm text-red-500">{errors.lastName}</p>}
</div>
// {/* Last Name */}
// <div className="space-y-2">
// <Label htmlFor="lastName">{t.lastName}</Label>
// <Input
// id="lastName"
// name="lastName"
// value={formData.lastName}
// onChange={handleInputChange}
// className={errors.lastName ? "border-red-500" : ""}
// />
// {errors.lastName && <p className="text-sm text-red-500">{errors.lastName}</p>}
// </div>
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">{t.email}</Label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleInputChange}
className={errors.email ? "border-red-500" : ""}
/>
{errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
</div>
// {/* Email */}
// <div className="space-y-2">
// <Label htmlFor="email">{t.email}</Label>
// <Input
// id="email"
// name="email"
// type="email"
// value={formData.email}
// onChange={handleInputChange}
// className={errors.email ? "border-red-500" : ""}
// />
// {errors.email && <p className="text-sm text-red-500">{errors.email}</p>}
// </div>
{/* Phone */}
<div className="space-y-2">
<Label htmlFor="phone">{t.phone}</Label>
<Input
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
placeholder="+99361111111"
className={errors.phone ? "border-red-500" : ""}
/>
{errors.phone && <p className="text-sm text-red-500">{errors.phone}</p>}
</div>
// {/* Phone */}
// <div className="space-y-2">
// <Label htmlFor="phone">{t.phone}</Label>
// <Input
// id="phone"
// name="phone"
// value={formData.phone}
// onChange={handleInputChange}
// placeholder="+99361111111"
// className={errors.phone ? "border-red-500" : ""}
// />
// {errors.phone && <p className="text-sm text-red-500">{errors.phone}</p>}
// </div>
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="file">{t.uploadPatent}</Label>
<div className="flex flex-col gap-2">
<Input id="file" type="file" accept=".pdf,.jpg,.jpeg" onChange={handleFileChange} className="hidden" />
<Button
type="button"
variant="outline"
className="w-full bg-transparent"
onClick={() => document.getElementById("file")?.click()}
>
<Upload className="mr-2 h-4 w-4" />
{t.uploadPatent}
</Button>
{fileName && (
<p className="text-sm text-gray-600">
{t.selectedFile}: {fileName}
</p>
)}
{errors.file && <p className="text-sm text-red-500">{errors.file}</p>}
</div>
</div>
// {/* File Upload */}
// <div className="space-y-2">
// <Label htmlFor="file">{t.uploadPatent}</Label>
// <div className="flex flex-col gap-2">
// <Input id="file" type="file" accept=".pdf,.jpg,.jpeg" onChange={handleFileChange} className="hidden" />
// <Button
// type="button"
// variant="outline"
// className="w-full bg-transparent"
// onClick={() => document.getElementById("file")?.click()}
// >
// <Upload className="mr-2 h-4 w-4" />
// {t.uploadPatent}
// </Button>
// {fileName && (
// <p className="text-sm text-gray-600">
// {t.selectedFile}: {fileName}
// </p>
// )}
// {errors.file && <p className="text-sm text-red-500">{errors.file}</p>}
// </div>
// </div>
{/* Submit Button */}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Загрузка..." : t.submit}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
// {/* Submit Button */}
// <Button type="submit" className="w-full" disabled={loading}>
// {loading ? "Загрузка..." : t.submit}
// </Button>
// </form>
// </CardContent>
// </Card>
// </div>
// )
// }

View File

@@ -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<Metadata> {
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<Metadata> {
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 <ProductPageContent slug={params.slug} />
return <ProductPageContent slug={params.slug} />;
}

View File

@@ -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 (
<div className="min-h-[400px] flex flex-col items-center justify-center p-4">
<Search className="h-16 w-16 text-gray-300 mb-4" />
<h2 className="text-2xl font-semibold text-gray-600 mb-2">{message}</h2>
{query && (
<p className="text-gray-500 mb-6 text-center max-w-sm">
{locale === "ru" ? `No products found for "${query}"` : `No products found for "${query}"`}
</p>
)}
<Link href={actionHref}>
<Button className="rounded-xl">{actionText}</Button>
</Link>
</div>
)
}

View File

@@ -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 ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
{t.catalog}
{t("common.catalog")}
</Button>
<div className="flex items-center gap-2 sm:hidden">
@@ -135,55 +91,25 @@ export default function Header({ locale = "ru", translations }: HeaderProps) {
<LanguageSelector />
</div>
<SearchBar isMobile={false} searchPlaceholder={t.search} className="hidden flex-1 md:flex" />
<SearchBar
isMobile={false}
searchPlaceholder={t("common.search")}
className="hidden flex-1 md:flex"
locale={locale}
/>
<div className="hidden md:flex items-center gap-2">
{isLoading ? (
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />
) : isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2">
<UserIcon className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t.profile}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => (window.location.href = `/${locale}/me`)}>
<UserIcon className="mr-2 h-4 w-4" />
{t.profile}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" />
{isLoggingOut ? t.loggingOut : t.logout}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={handleAuthClick}>
<UserIcon className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t.login}</span>
</Button>
)}
</div>
<ActionButtons
isAuthenticated={isAuthenticated}
onAuthClick={handleAuthClick}
translations={{
profile: t.profile,
login: t.login,
orders: t.orders,
favorites: t.favorites,
cart: t.cart,
}}
/>
</div>
<Link href="/openStore">
<Button variant="ghost" size="sm" className="relative flex gap-0.5 h-auto pb-2">
<Store className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t.openStore}</span>
<span className="text-xs text-gray-700">{t("common.openStore")}</span>
</Button>
</Link>
</div>
@@ -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}
/>
<AuthDialog
isOpen={isLoginOpen}
onClose={() => 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,
}}
/>
</>
)
);
}

View File

@@ -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 buttons: ActionButtonData[] = [
{
icon: <User className="h-5 w-5 text-gray-600" />,
label: isAuthenticated ? t.profile : t.login,
onClick: onAuthClick,
},
const { data: cartData, isLoading: cartLoading } = useCart();
const { data: favoritesData, isLoading: favoritesLoading } = useFavorites();
const { data: ordersData, isLoading: ordersLoading } = useOrders();
// 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: <Truck className="h-5 w-5 text-gray-600" />,
label: t.orders,
label: t("common.orders"),
href: "/orders",
badgeCount: ordersData?.length || 0,
badgeCount: ordersCount,
isLoading: ordersLoading,
},
{
icon: <Heart className="h-5 w-5 text-gray-600" />,
label: t.favorites,
label: t("common.favorites"),
href: "/favorites",
badgeCount: favoritesData?.length || 0,
badgeCount: favoritesCount,
isLoading: favoritesLoading,
},
{
icon: <ShoppingCart className="h-5 w-5 text-gray-600" />,
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 (
<div className="hidden items-center gap-1 md:flex">
{/* Profile/Login Button with Dropdown */}
{authLoading ? (
<div className="h-10 w-24 animate-pulse bg-gray-200 rounded" />
) : isAuthenticated ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2">
<User className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t("profile")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => (window.location.href = `/${locale}/me`)}>
<User className="mr-2 h-4 w-4" />
{t("profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={handleLogout} disabled={isLoggingOut}>
<LogOut className="mr-2 h-4 w-4" />
{isLoggingOut ? t("logging_out") : t("common.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button variant="ghost" size="sm" className="flex-col gap-0.5 h-auto px-2 py-2" onClick={onAuthClick}>
<User className="h-5 w-5 text-gray-600" />
<span className="text-xs text-gray-700">{t("common.login")}</span>
</Button>
)}
{/* Other Action Buttons */}
{buttons.map((button, index) => (
<ActionButton key={index} {...button} />
))}
</div>
)
);
}
function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: ActionButtonData) {
@@ -77,7 +136,7 @@ function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: Act
<Button variant="ghost" size="sm" className="relative flex-col gap-0.5 h-auto px-2 py-2" onClick={onClick}>
<div className="relative">
{icon}
{badgeCount !== undefined && (
{badgeCount !== undefined && badgeCount > 0 && (
<Badge
variant="destructive"
className="absolute -right-2 -top-2 h-4 w-4 flex items-center justify-center p-0 text-[10px]"
@@ -88,11 +147,11 @@ function ActionButton({ icon, label, href, onClick, badgeCount, isLoading }: Act
</div>
<span className="text-xs text-gray-700">{label}</span>
</Button>
)
);
if (href) {
return <Link href={href}>{buttonContent}</Link>
return <Link href={href}>{buttonContent}</Link>;
}
return buttonContent
return buttonContent;
}

View File

@@ -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 cleaned.substring(0, 11)
return "993";
}
return cleaned.substring(0, 11);
}, []);
return (
<Dialog open={isOpen} onOpenChange={resetDialog}>
@@ -117,15 +105,15 @@ export default function AuthDialog({ isOpen, onClose, translations: t }: AuthDia
<Image src={Logo} alt="Logo" fill className="object-contain" />
</div>
</div>
<DialogTitle className="text-2xl text-center">{t.enterPhone}</DialogTitle>
<p className="text-center text-sm text-gray-600">{t.weWillSendCode}</p>
<DialogTitle className="text-2xl text-center">{t("common.enterPhone")}</DialogTitle>
<p className="text-center text-sm text-gray-600">{t("common.weWillSendCode")}</p>
</DialogHeader>
<div className="space-y-4 mt-4">
<div>
<Input
type="tel"
placeholder={t.phone}
placeholder={t("common.phone")}
value={phone}
onChange={(e) => 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}
/>
<p className="text-xs text-gray-500 mt-1">Format: 99365123456</p>
<p className="text-xs text-gray-500 mt-1">{t("phone_format")}</p>
</div>
{otpSent && (
<Input
type="text"
placeholder={t.code}
placeholder={t("common.code")}
value={otp}
onChange={(e) => 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")}
</Button>
</div>
</DialogContent>
</Dialog>
)
);
}

View File

@@ -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

View File

@@ -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<HTMLDivElement>(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 (
<div className="absolute top-full left-0 right-0 mt-2 bg-white border rounded-xl shadow-lg max-h-[400px] overflow-y-auto z-50">
{data.data.map((product) => (
<button
key={product.id}
onClick={() => handleProductClick(product.id)}
className="w-full flex items-center gap-3 p-3 hover:bg-gray-50 transition-colors border-b last:border-b-0"
>
<div className="relative w-16 h-16 flex-shrink-0">
<Image
src={product.thumbnail}
alt={product.name}
fill
className="object-cover rounded-lg"
/>
</div>
<div className="flex-1 text-left">
<p className="font-medium text-sm line-clamp-2">{product.name}</p>
<p className="text-sm text-gray-600 mt-1">
{product.price_amount} TMT
</p>
<p className="text-xs text-gray-500">{product.brand.name}</p>
</div>
</button>
))}
</div>
);
};
if (isMobile) {
@@ -38,15 +121,19 @@ export default function SearchBar({
<DialogHeader>
<DialogTitle>{searchPlaceholder}</DialogTitle>
</DialogHeader>
<div className="relative">
<div className="relative" ref={searchRef}>
<Input
type="search"
type="text"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => 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 && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-400" />
)}
<SearchResults />
</div>
</DialogContent>
</Dialog>
@@ -54,15 +141,18 @@ export default function SearchBar({
}
return (
<div className={`bg-[#005bff] rounded-xl ${className}`}>
<div className="w-full">
<div className={`bg-[#005bff] rounded-xl flex items-center relative ${className}`} ref={searchRef}>
<div className="w-full relative">
<Input
type="search"
type="text"
placeholder={searchPlaceholder}
value={searchValue}
onChange={(e) => handleSearch(e.target.value)}
className="border-[#005bff] w-full rounded-xl border-2 focus-visible:ring-0 bg-white px-2"
/>
{isLoading && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 animate-spin text-gray-400" />
)}
</div>
<Button
size="icon"
@@ -70,6 +160,7 @@ export default function SearchBar({
>
<Search className="h-5 w-5" />
</Button>
<SearchResults />
</div>
);
}

View File

@@ -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 (
<div className="px-4 md:px-8 lg:px-12 pt-8 pb-12 space-y-8">
{/* Hero Banner */}
<Skeleton className="w-full h-[300px] rounded-2xl bg-gray-200" />
{/* Categories */}
<div className="space-y-4">
<Skeleton className="h-6 w-32 bg-gray-200" />
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="aspect-square bg-gray-200 rounded-xl" />
))}
</div>
</div>
{/* Products */}
<div className="space-y-4">
<Skeleton className="h-6 w-32 bg-gray-200" />
<ProductGridSkeleton count={8} columns="5" />
</div>
</div>
)
case "products":
case "search":
return (
<div className="px-4 md:px-8 lg:px-12 py-8">
<div className="space-y-4 mb-6">
<Skeleton className="h-8 w-40 bg-gray-200" />
</div>
<ProductGridSkeleton count={12} columns="5" />
</div>
)
case "category":
return (
<div className="container mx-auto px-4 py-8">
<div className="flex gap-6">
{/* Filters Sidebar */}
<div className="hidden sm:block w-[280px] space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-5 w-24 bg-gray-200" />
<Skeleton className="h-4 w-full bg-gray-200" />
<Skeleton className="h-4 w-full bg-gray-200" />
<Skeleton className="h-4 w-3/4 bg-gray-200" />
</div>
))}
</div>
{/* Products */}
<div className="flex-1">
<ProductGridSkeleton count={12} columns="5" />
</div>
</div>
</div>
)
case "cart":
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<CartItemSkeleton key={i} />
))}
</div>
{/* Order Summary */}
<div className="lg:w-[420px]">
<div className="space-y-4 bg-gray-50 p-6 rounded-xl">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full bg-gray-200" />
))}
</div>
</div>
</div>
</div>
)
case "orders":
case "favorites":
return (
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-64 w-full bg-gray-200 rounded-xl" />
))}
</div>
</div>
)
case "profile":
return (
<div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl">
<Skeleton className="h-8 w-40 mb-6 bg-gray-200" />
<div className="bg-white p-6 rounded-xl space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32 bg-gray-200" />
<Skeleton className="h-10 w-full bg-gray-200 rounded-lg" />
</div>
))}
</div>
</div>
</div>
)
default:
return <ProductGridSkeleton count={12} columns="5" />
}
}

View File

@@ -1,113 +1,303 @@
"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<NodeJS.Timeout>()
// Sync State
const [isSyncing, setIsSyncing] = useState(false)
const [syncError, setSyncError] = useState(false)
// Stock limit modal
const [showStockModal, setShowStockModal] = useState(false)
// Refs
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined)
const isRequestInFlightRef = useRef(false)
const pendingQuantityRef = useRef<number | null>(null)
const retryCountRef = useRef(0)
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(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<number, PendingUpdate> = stored ? JSON.parse(stored) : {}
if (updateTimeoutRef.current) {
clearTimeout(updateTimeoutRef.current)
pending[item.product_id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current
}
updateTimeoutRef.current = setTimeout(() => {
setIsLoading(true)
sessionStorage.setItem(PENDING_CART_UPDATES_KEY, JSON.stringify(pending))
} catch (error) {
console.error('Failed to save pending update:', error)
}
}, [item.product_id])
if (pendingQuantity <= 0) {
// Remove from sessionStorage
const clearPendingUpdate = useCallback(() => {
try {
const stored = sessionStorage.getItem(PENDING_CART_UPDATES_KEY)
if (stored) {
const pending: Record<number, PendingUpdate> = 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
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000) // Max 16s
retryCountRef.current++
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity)
}, delay)
}, [])
// 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: () => onUpdate?.(),
onError: () => {
setLocalQuantity(item.quantity)
setPendingQuantity(item.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)
}
},
onSettled: () => setIsLoading(false),
onError: (error) => {
console.error('Remove failed:', error)
isRequestInFlightRef.current = false
retrySyncRef.current?.(quantity)
}
})
} else {
updateQuantity(
{ productId: item.product_id, quantity: pendingQuantity },
{ productId: item.product_id, quantity },
{
onSuccess: () => onUpdate?.(),
onError: () => {
setLocalQuantity(item.quantity)
setPendingQuantity(item.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)
}
},
onSettled: () => setIsLoading(false),
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)
}
}
)
}
}, 300)
}, [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<number, PendingUpdate> = 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)
}
}
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 (
<>
<Card className="p-4 shadow-none border">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex gap-4 flex-1">
@@ -117,11 +307,16 @@ export default function CartItemCard({ item, translations: t, onUpdate }: CartIt
<div className="flex flex-col gap-2">
<h3 className="font-semibold text-base">{item.product.name}</h3>
<p className="text-sm text-gray-600">{item.seller?.name || "Store"}</p>
{availableStock <= 5 && (
<p className="text-xs text-orange-600 font-medium">
{t("only_left", { count: availableStock })}
</p>
)}
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isRemoving || isLoading}
disabled={isRemoving}
className="w-fit p-0 h-auto hover:bg-transparent hover:text-red-500"
>
<Trash2 className="h-5 w-5" />
@@ -132,18 +327,18 @@ export default function CartItemCard({ item, translations: t, onUpdate }: CartIt
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold">
{t.pricePerUnit} <span className="text-primary">{item.price_formatted}</span>
{t("unit_price")} <span className="text-primary">{item.price_formatted}</span>
</p>
<p className="text-sm font-semibold">
{t.additionalPrice} {item.sub_total_formatted}
{t("extra_price")} {item.sub_total_formatted}
</p>
{item.discount_formatted && item.discount_formatted !== "0 TMT" && (
<p className="text-sm font-semibold">{t.discount} {item.discount_formatted}</p>
<p className="text-sm font-semibold">{t("discount")} {item.discount_formatted}</p>
)}
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">{t.totalPrice}</span>
<span className="text-sm font-semibold">{t("total_price")}</span>
<span className="bg-green-500 text-white px-3 py-1 rounded-xl font-semibold text-base">
{item.total_formatted}
{(parseFloat(item.product.price_amount || "0") * localQuantity).toFixed(2)} TMT
</span>
</div>
</div>
@@ -153,18 +348,29 @@ export default function CartItemCard({ item, translations: t, onUpdate }: CartIt
variant="outline"
size="icon"
onClick={handleQuantityDecrease}
disabled={isLoading || isRemoving}
className="rounded-xl bg-blue-50"
className={`rounded-xl bg-blue-50 ${isSyncing ? 'opacity-70' : ''}`}
>
<Minus className="h-4 w-4" />
</Button>
<div className="w-12 text-center font-semibold">{localQuantity}</div>
<div className="w-12 text-center font-semibold relative">
{localQuantity}
{isSyncing && (
<Loader2 className="h-3 w-3 animate-spin absolute -top-1 -right-3 text-blue-500" />
)}
{syncError && (
<span className="absolute -top-1 -right-3 h-2 w-2 bg-red-500 rounded-full" title="Sync error" />
)}
</div>
<Button
variant="outline"
size="icon"
onClick={handleQuantityIncrease}
disabled={isLoading || isRemoving}
className="rounded-xl bg-blue-50"
disabled={localQuantity >= availableStock}
className={`rounded-xl bg-blue-50 ${isSyncing ? 'opacity-70' : ''} ${
localQuantity >= availableStock ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Plus className="h-4 w-4" />
</Button>
@@ -172,5 +378,36 @@ export default function CartItemCard({ item, translations: t, onUpdate }: CartIt
</div>
</div>
</Card>
{/* Stock Limit Modal */}
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: item.product.name,
stock: availableStock
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={() => setShowStockModal(false)}
className="w-full rounded-xl"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
)
}

View File

@@ -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 (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t.deliveryType}</h3>
<h3 className="text-lg font-semibold mb-3">{t("delivery_type")}</h3>
<div className="flex gap-2">
{deliveryOptions.map(({ type, label, icon: Icon }) => (
<Card

View File

@@ -1,37 +1,58 @@
"use client"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { Textarea } from "@/components/ui/textarea"
import { Separator } from "@/components/ui/separator"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import DeliveryTypeSelector from "./DeliveryTypeSelector"
import type { Order, Province, DeliveryType, CartTranslations, PaymentType } from "../types"
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import DeliveryTypeSelector from "./DeliveryTypeSelector";
import { useTranslations } from "next-intl";
import type { DeliveryType, PaymentType, Province } from "@/lib/types/api";
interface OrderBillingItem {
title: string;
value: string;
}
interface OrderBilling {
body: OrderBillingItem[];
footer: {
title: string;
value: string;
};
}
interface OrderSummaryProps {
order: Order
translations: CartTranslations
paymentType: PaymentType | null
deliveryType: DeliveryType
selectedRegion: string
selectedProvince: number | null
note: string
regionGroups: Record<string, Province[]>
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<string, Province[]>;
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,14 +69,18 @@ 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 (
<Card className="w-full md:w-[380px] p-6 rounded-xl h-fit sticky top-20">
{/* Payment Type */}
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</h3>
<h3 className="text-lg font-semibold mb-3">{t("payment_type")}</h3>
<div className="flex gap-2">
{paymentTypes.map((type) => (
<Card
@@ -68,9 +93,11 @@ export default function OrderSummary({
onClick={() => onPaymentTypeChange(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<span className={`text-xs font-medium ${
<span
className={`text-xs font-medium ${
paymentType?.id === type.id ? "text-[#005bff]" : ""
}`}>
}`}
>
{type.name}
</span>
</div>
@@ -83,17 +110,18 @@ export default function OrderSummary({
<DeliveryTypeSelector
selectedType={deliveryType}
onSelect={onDeliveryTypeChange}
translations={t}
/>
{/* Region Selection */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.selectRegion}</Label>
<Label className="text-lg font-semibold mb-3 block">
{t("choose_region")}
</Label>
<RadioGroup
value={selectedRegion}
onValueChange={(value) => {
onRegionChange(value)
onProvinceChange(null as any)
onRegionChange(value);
onProvinceChange(null as any);
}}
className="flex flex-wrap gap-4"
>
@@ -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"
/>
<Label htmlFor={`region-${regionCode}`} className="cursor-pointer uppercase">
<Label
htmlFor={`region-${regionCode}`}
className="cursor-pointer uppercase"
>
{regionCode}
</Label>
</div>
@@ -115,13 +146,15 @@ export default function OrderSummary({
{/* Province Selection */}
{selectedRegion && provincesForSelectedRegion.length > 0 && (
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.selectAddress}</Label>
<Label className="text-lg font-semibold mb-3 block">
{t("choose_address")}
</Label>
<Select
value={selectedProvince?.toString() || ""}
onValueChange={(value) => onProvinceChange(parseInt(value))}
>
<SelectTrigger className="rounded-xl">
<SelectValue placeholder={t.selectAddress} />
<SelectValue placeholder={t("choose_address")} />
</SelectTrigger>
<SelectContent>
{provincesForSelectedRegion.map((province) => (
@@ -136,20 +169,23 @@ export default function OrderSummary({
{/* Note */}
<div className="mb-6">
<Label className="text-lg font-semibold mb-3 block">{t.note}</Label>
<Label className="text-lg font-semibold mb-3 block">{t("note")}</Label>
<Textarea
value={note}
onChange={(e) => onNoteChange(e.target.value)}
className="rounded-xl resize-none"
rows={3}
placeholder={t.note}
placeholder={t("note")}
/>
</div>
{/* Billing */}
<div className="space-y-2 mb-4">
{order.billing.body.map((item, index) => (
<div key={index} className="flex justify-between text-base font-medium">
<div
key={index}
className="flex justify-between text-base font-medium"
>
<span>{item.title}:</span>
<span>{item.value}</span>
</div>
@@ -159,8 +195,12 @@ export default function OrderSummary({
<Separator className="my-4" />
<div className="flex justify-between items-center mb-6">
<span className="text-lg font-semibold">{order.billing.footer.title}:</span>
<span className="text-lg font-bold text-green-600">{order.billing.footer.value}</span>
<span className="text-lg font-semibold">
{order.billing.footer.title}:
</span>
<span className="text-lg font-bold text-green-600">
{order.billing.footer.value}
</span>
</div>
<Button
@@ -169,8 +209,8 @@ export default function OrderSummary({
className="w-full rounded-xl bg-[#005bff] hover:bg-[#004dcc] h-12 text-lg font-bold disabled:opacity-50"
size="lg"
>
{isLoading ? `${t.placeOrder}...` : t.placeOrder}
{isLoading ? `${t("order")}...` : t("order")}
</Button>
</Card>
)
);
}

View File

@@ -1,49 +0,0 @@
import React from "react";
import { CreditCard } from "lucide-react";
import { Card } from "@/components/ui/card";
import { PaymentType, CartTranslations } from "./types";
interface PaymentTypeSelectorProps {
selectedType: PaymentType;
onSelect: (type: PaymentType) => void;
translations: CartTranslations;
}
export default function PaymentTypeSelector({
selectedType,
onSelect,
translations: t,
}: PaymentTypeSelectorProps) {
const paymentOptions: { type: PaymentType; label: string }[] = [
{ type: "CASH", label: t.cash },
{ type: "CARD", label: t.card },
];
return (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-3">{t.paymentType}</h3>
<div className="flex gap-2">
{paymentOptions.map(({ type, label }) => (
<Card
key={type}
className={`flex-1 cursor-pointer transition-all ${
selectedType === type
? "border-2 border-[#005bff]"
: "border-2 border-gray-200"
}`}
onClick={() => onSelect(type)}
>
<div className="flex flex-col items-center justify-center p-4 gap-2">
<CreditCard
className={`h-8 w-8 ${
selectedType === type ? "text-[#005bff]" : ""
}`}
/>
<span className="text-xs">{label}</span>
</div>
</Card>
))}
</div>
</div>
);
}

View File

@@ -49,12 +49,13 @@ export function useCart(options?: Partial<UseQueryOptions<CartResponse>>) {
const response = await apiClient.get("/carts")
return transformCartResponse(response.data)
},
refetchInterval: 5000, // Poll every 5 seconds like RTK
refetchInterval: 10000, // Increased to 10 seconds (less aggressive)
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnWindowFocus: true, // Enable to catch updates on tab focus
refetchOnReconnect: true,
staleTime: 0,
retry: 1,
staleTime: 5000, // Data considered fresh for 5 seconds
retry: 2,
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
...options,
})
}
@@ -92,7 +93,8 @@ export function useAddToCart() {
return { message: "success", data: "Added to cart" }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
// Invalidate but don't refetch immediately (let polling handle it)
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' })
},
onError: (error: any) => {
console.error("Add to cart error:", error.response?.data?.message || error.message)
@@ -130,6 +132,7 @@ export function useRemoveFromCart() {
return []
},
onSuccess: () => {
// Immediate refetch after removal
queryClient.invalidateQueries({ queryKey: ["cart"] })
},
onError: (error: any) => {
@@ -185,6 +188,7 @@ export function useUpdateCartItemQuantity() {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
timeout: 15000, // 15 second timeout
})
if (typeof response.data === "object" && response.data.data) {
@@ -204,10 +208,12 @@ export function useUpdateCartItemQuantity() {
return { message: "success", data: "Updated cart" }
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cart"] })
// Invalidate but don't refetch immediately (let optimistic update handle it)
queryClient.invalidateQueries({ queryKey: ["cart"], refetchType: 'none' })
},
onError: (error: any) => {
console.error("API update failed:", error.response?.data?.message || error.message)
throw error // Re-throw to trigger retry mechanism
},
})
}
@@ -239,10 +245,3 @@ export function useCreateOrder() {
},
})
}

View File

@@ -1,171 +0,0 @@
import type { StaticImageData } from "next/image";
export interface Cart {
message: string;
data: CartItem[];
errorDetails?: string;
total?: number;
total_formatted?: string;
items?: CartItem[]; // Alternative structure
}
export interface Order {
id: number;
seller: {
id: number;
name: string;
};
items: CartItem[];
billing: {
body: Array<{ title: string; value: string }>;
footer: { title: string; value: string };
};
}
export interface Region {
id: number;
code: string;
name: string;
}
export interface Address {
id: number;
title: string;
region_id: number;
address: string;
phone?: string;
is_default?: boolean;
}
export interface PickUpPoint {
id: number;
name: string;
address: string;
}
export interface PaymentTypeOption {
id: number;
name: string;
code: string;
}
export interface CartTranslations {
cart: string;
ordersIn: string;
pricePerUnit: string;
additionalPrice: string;
discount: string;
totalPrice: string;
paymentType: string;
cash: string;
card: string;
deliveryType: string;
delivery: string;
pickup: string;
selectRegion: string;
selectAddress: string;
note: string;
placeOrder: string;
emptyCart: string;
map: string;
}
// API Response types
export interface ApiResponse<T> {
message: string;
data: T;
errorDetails?: string;
}
export interface CreateOrderPayload {
customer_name?: string;
customer_phone?: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}
export interface CartItem {
id: number;
product_id: number;
product: {
id: number;
name: string;
description?: string;
media?: Array<{ images_800x800?: string; thumbnail?: string }>;
channel?: Array<{ id: number; name: string }>;
price_amount?: string;
stock?: number;
};
product_quantity: number;
quantity?: number; // For compatibility
seller?: {
id: number;
name: string;
};
price?: number;
total?: number;
price_formatted?: string;
sub_total_formatted?: string;
discount_formatted?: string;
total_formatted?: string;
}
export interface Province {
id: number;
region: string;
name: string;
}
export interface PaymentType {
id: number;
name: string;
}
export interface Order {
id: number;
seller: {
id: number;
name: string;
};
items: CartItem[];
billing: {
body: Array<{ title: string; value: string }>;
footer: { title: string; value: string };
};
}
export interface CartTranslations {
cart: string;
ordersIn: string;
pricePerUnit: string;
additionalPrice: string;
discount: string;
totalPrice: string;
paymentType: string;
cash: string;
card: string;
deliveryType: string;
delivery: string;
pickup: string;
selectRegion: string;
selectAddress: string;
note: string;
placeOrder: string;
emptyCart: string;
map: string;
}
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface CreateOrderPayload {
customer_address: string;
shipping_method: string;
payment_type_id: number;
region: string;
note?: string;
}

View File

@@ -2,9 +2,8 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ChevronLeft, SlidersHorizontal, X } from "lucide-react";
import { SlidersHorizontal, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
@@ -18,7 +17,6 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import InfiniteScroll from "react-infinite-scroll-component";
import ProductCard from "@/components/ProductCard";
import Loader from "@/components/Loader";
import {
useCategories,
useAllCategoryProducts,
@@ -43,7 +41,8 @@ export default function CategoryPageClient({
const t = useTranslations();
// Fetch all categories first
const { data: categoriesData, isLoading: categoriesLoading } = useCategories();
const { data: categoriesData, isLoading: categoriesLoading } =
useCategories();
// Find category from slug
const selectedCategory = useMemo(() => {
@@ -65,7 +64,9 @@ export default function CategoryPageClient({
// Track subcategories
const [hasSubcategories, setHasSubcategories] = useState(false);
const [subcategoriesToShow, setSubcategoriesToShow] = useState<Category[]>([]);
const [subcategoriesToShow, setSubcategoriesToShow] = useState<Category[]>(
[]
);
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
@@ -73,13 +74,17 @@ export default function CategoryPageClient({
const [allProducts, setAllProducts] = useState<Product[]>([]);
// Price sorting state
const [priceSort, setPriceSort] = useState<"none" | "lowToHigh" | "highToLow">("none");
const [priceSort, setPriceSort] = useState<
"none" | "lowToHigh" | "highToLow"
>("none");
// Price filter state
const [priceRange, setPriceRange] = useState<[number, number]>([0, 10000]);
// Selected filters state
const [selectedFilters, setSelectedFilters] = useState<Record<string, Set<number>>>({
const [selectedFilters, setSelectedFilters] = useState<
Record<string, Set<number>>
>({
brand: new Set(),
color: new Set(),
tag: new Set(),
@@ -89,7 +94,10 @@ export default function CategoryPageClient({
const isSubCategory = useMemo(() => {
if (!categoriesData || !selectedCategory) return false;
const checkIsSubCategory = (categories: Category[], targetId: number): boolean => {
const checkIsSubCategory = (
categories: Category[],
targetId: number
): boolean => {
for (const category of categories) {
if (category.children) {
for (const subCategory of category.children) {
@@ -134,17 +142,13 @@ export default function CategoryPageClient({
limit: 6,
});
if (!slug) {
notFound();
}
// Helper function to find category by ID
const findCategoryById = (
categories: Category[] | undefined,
id: number
): Category | null => {
const findCategoryById = useCallback(
(categories: Category[] | undefined, id: number): Category | null => {
if (!categories) return null;
for (const category of categories) {
@@ -155,22 +159,25 @@ export default function CategoryPageClient({
}
}
return null;
};
},
[]
);
// Helper to check if product already exists in list
const isProductInList = (list: Product[], newProduct: Product) => {
const isProductInList = useCallback(
(list: Product[], newProduct: Product) => {
return list.some((product) => product.id === newProduct.id);
};
},
[]
);
// Setup subcategories when category changes
useEffect(() => {
if (selectedCategory) {
// Reset states
setAllProducts([]);
setHasMore(true);
setCurrentPage(1);
// Set subcategories
if (selectedCategory.children && selectedCategory.children.length > 0) {
setHasSubcategories(true);
setSubcategoriesToShow(selectedCategory.children);
@@ -189,17 +196,14 @@ export default function CategoryPageClient({
subcategoryProducts.length > 0 &&
currentPage === 1
) {
console.log("Setting subcategory products:", subcategoryProducts.length);
setAllProducts(subcategoryProducts);
setHasMore(true);
}
}, [selectedCategory, subcategoryProducts, currentPage, isSubCategory]);
// Handle paginated category products (non-subcategories) - FIXED
// Handle paginated category products (non-subcategories)
useEffect(() => {
if (paginatedCategoryData && selectedCategory && !isSubCategory) {
console.log("Paginated category data:", paginatedCategoryData);
if (paginatedCategoryData.data && paginatedCategoryData.data.length > 0) {
setAllProducts((prevProducts) => {
if (currentPage === 1) {
@@ -213,14 +217,19 @@ export default function CategoryPageClient({
return [...prevProducts, ...newProducts];
});
// FIXED: Check next_page_url instead of pagination object existence
setHasMore(!!paginatedCategoryData.pagination?.next_page_url);
} else if (currentPage === 1) {
setAllProducts([]);
setHasMore(false);
}
}
}, [paginatedCategoryData, currentPage, selectedCategory, isSubCategory]);
}, [
paginatedCategoryData,
currentPage,
selectedCategory,
isSubCategory,
isProductInList,
]);
// Handle paginated subcategory products
useEffect(() => {
@@ -230,8 +239,6 @@ export default function CategoryPageClient({
isSubCategory &&
currentPage > 1
) {
console.log("Paginated subcategory data:", paginatedSubcategoryData);
if (
paginatedSubcategoryData.data &&
paginatedSubcategoryData.data.length > 0
@@ -249,16 +256,20 @@ export default function CategoryPageClient({
setHasMore(false);
}
}
}, [paginatedSubcategoryData, currentPage, selectedCategory, isSubCategory]);
}, [
paginatedSubcategoryData,
currentPage,
selectedCategory,
isSubCategory,
isProductInList,
]);
const loadMoreData = useCallback(() => {
if (!hasMore || categoryPaginatedFetching || subcategoryPaginatedLoading) {
console.log("Cannot load more:", { hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading });
return;
}
console.log("Loading more, current page:", currentPage, "next page:", currentPage + 1);
setCurrentPage((prevPage) => prevPage + 1);
}, [hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading, currentPage]);
}, [hasMore, categoryPaginatedFetching, subcategoryPaginatedLoading]);
const isLoading =
categoriesLoading ||
@@ -294,27 +305,36 @@ export default function CategoryPageClient({
return products.length || 0;
}, [paginatedCategoryData, products, isSubCategory, selectedCategory]);
const handlePriceSortChange = (sortType: "none" | "lowToHigh" | "highToLow") => {
const handlePriceSortChange = useCallback(
(sortType: "none" | "lowToHigh" | "highToLow") => {
setPriceSort(sortType);
};
},
[]
);
const handleSubCategorySelect = (subCategory: Category) => {
const handleSubCategorySelect = useCallback(
(subCategory: Category) => {
setAllProducts([]);
setCurrentPage(1);
setHasMore(true);
setPriceSort("none");
router.push(`/${locale}/category/${subCategory.slug}`, { scroll: false });
};
},
[locale, router]
);
const handleCategoryClick = (category: Category) => {
const handleCategoryClick = useCallback(
(category: Category) => {
setAllProducts([]);
setCurrentPage(1);
setHasMore(true);
router.push(`/${locale}/category/${category.slug}`);
};
},
[locale, router]
);
const renderBreadcrumbs = () => {
const renderBreadcrumbs = useCallback(() => {
if (!categoriesData || !selectedCategory) return null;
const breadcrumbs: Category[] = [];
@@ -348,11 +368,11 @@ export default function CategoryPageClient({
))}
</div>
);
};
}, [categoriesData, selectedCategory, findCategoryById, handleCategoryClick]);
const pageTitle = selectedCategory?.name || t("category");
const handleFilterChange = (key: string, value: number) => {
const handleFilterChange = useCallback((key: string, value: number) => {
setSelectedFilters((prev) => {
const newFilters = { ...prev };
if (!newFilters[key]) {
@@ -367,22 +387,25 @@ export default function CategoryPageClient({
return newFilters;
});
};
}, []);
const handlePriceChange = (values: number[]) => {
const handlePriceChange = useCallback((values: number[]) => {
setPriceRange([values[0], values[1]]);
};
}, []);
const handlePriceInputChange = (type: "from" | "to", value: string) => {
const handlePriceInputChange = useCallback(
(type: "from" | "to", value: string) => {
const numValue = parseInt(value) || 0;
if (type === "from") {
setPriceRange([numValue, priceRange[1]]);
setPriceRange((prev) => [numValue, prev[1]]);
} else {
setPriceRange([priceRange[0], numValue]);
setPriceRange((prev) => [prev[0], numValue]);
}
};
},
[]
);
const resetFilters = () => {
const resetFilters = useCallback(() => {
setSelectedFilters({
brand: new Set(),
color: new Set(),
@@ -390,9 +413,10 @@ export default function CategoryPageClient({
});
setPriceRange([0, 10000]);
setPriceSort("none");
};
}, []);
const FiltersContent = () => (
const FiltersContent = useCallback(
() => (
<div className="space-y-6">
{hasSubcategories && subcategoriesToShow.length > 0 && (
<div>
@@ -416,7 +440,7 @@ export default function CategoryPageClient({
)}
<div>
<h3 className="text-lg font-semibold mb-3">{t("composition")}</h3>
<h3 className="text-lg font-semibold mb-3">{t("sort")}</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -426,7 +450,7 @@ export default function CategoryPageClient({
onChange={() => handlePriceSortChange("none")}
className="w-4 h-4"
/>
<span>{t("neverMind")}</span>
<span>{t("default")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -436,7 +460,7 @@ export default function CategoryPageClient({
onChange={() => handlePriceSortChange("lowToHigh")}
className="w-4 h-4"
/>
<span>{t("fromCheapToExpensive")}</span>
<span>{t("price_low_to_high")}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
@@ -446,7 +470,7 @@ export default function CategoryPageClient({
onChange={() => handlePriceSortChange("highToLow")}
className="w-4 h-4"
/>
<span>{t("fromExpensiveToHigh")}</span>
<span>{t("price_high_to_low")}</span>
</label>
</div>
</div>
@@ -456,7 +480,7 @@ export default function CategoryPageClient({
priceRange={priceRange}
onPriceChange={handlePriceChange}
onInputChange={handlePriceInputChange}
translations={{ from: t("from"), to: t("to") }}
translations={{ from: t("price_from"), to: t("price_to") }}
/>
<Button
@@ -467,31 +491,34 @@ export default function CategoryPageClient({
{t("reset")}
</Button>
</div>
),
[
hasSubcategories,
subcategoriesToShow,
slug,
priceSort,
priceRange,
t,
handleSubCategorySelect,
handlePriceSortChange,
handlePriceChange,
handlePriceInputChange,
resetFilters,
]
);
if (isLoading) return <div>{t("loading") || "Ýüklenýär..."}</div>;
if (isLoading) return <div>{t("common.loading")}</div>;
if (!selectedCategory && !categoriesLoading) {
return <div className="text-center py-8">Bölüm tapylmady</div>;
return <div className="text-center py-8">{t("category_not_found")}</div>;
}
console.log(
"Current state - products:",
products.length,
"hasMore:",
hasMore,
"page:",
currentPage,
"isFetching:",
categoryPaginatedFetching
);
return (
<div className="flex flex-col gap-4">
{selectedCategory && renderBreadcrumbs()}
<h2 className="text-3xl font-bold">{pageTitle}</h2>
<p className="text-gray-600">
{t("total")}: {totalItems} {t("items")}
{t("total")}: {totalItems} {t("products")}
</p>
<div className="flex gap-4">
@@ -513,7 +540,7 @@ export default function CategoryPageClient({
style={{ overflow: "visible" }}
loader={
<div className="flex justify-center py-4">
<div>Ýüklenýär...</div>
<div>{t("common.loading")}</div>
</div>
}
>
@@ -536,7 +563,9 @@ export default function CategoryPageClient({
</div>
</InfiniteScroll>
) : (
<div className="text-center py-8 text-gray-500">{t("nResults")}</div>
<div className="text-center py-8 text-gray-500">
{t("no_results")}
</div>
)}
</div>
</div>
@@ -560,7 +589,7 @@ export default function CategoryPageClient({
className="absolute top-4 right-4 rounded-md ring-offset-background transition-opacity hover:opacity-100"
>
<X className="h-4 w-4" />
<span className="sr-only">Ýap</span>
<span className="sr-only">{t("close")}</span>
</button>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-80px)] p-4">

View File

@@ -1,9 +0,0 @@
"use client"
export default function CategoryPageContent({ slug }: { slug: string }) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Category: {slug}</h1>
{/* Category content will go here */}
</div>
)
}

View File

@@ -50,7 +50,7 @@ export default function CategoryGrid({
return (
<section className="bg-white rounded-2xl shadow-sm p-6">
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{categories?.map((cat) => (
<Link
key={cat.id}

View File

@@ -1,6 +1,6 @@
import { Skeleton } from "@/components/ui/skeleton"
import ProductGridSkeleton from "./ProductGridSkeleton"
import CategorySkeleton from "./CategorySkeleton"
import CategorySkeleton from "../../category/components/CategorySkeleton"
export default function HomeSkeleton() {
return (

View File

@@ -76,7 +76,7 @@ export default function CollectionSection({ collection, locale }: Props) {
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-4">
{displayProducts.map((product) => {
// Extract first media image or use placeholder
const firstImage =

View File

@@ -101,7 +101,7 @@ export function useCollectionHasProducts(
queryFn: async () => {
const response = await apiClient.get<PaginatedResponse<Product>>(
`/collections/${collectionId}/products`,
{ params: { perPage: 1 } }
{ params: { perPage: 20 } }
);
return {
hasProducts: response.data.data && response.data.data.length > 0,

View File

View File

@@ -1,46 +0,0 @@
"use client"
import { useState } from "react"
// ... existing types and code ...
interface OrdersContentProps {
locale: string
}
export default function OrdersPageContent({ locale }: OrdersContentProps) {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null)
const [activeTab, setActiveTab] = useState<"active" | "completed">("active")
const t = {
orders: "Заказы",
active: "Активные",
completed: "Завершенные",
cancelOrder: "Отменить заказ",
areYouSure: "Вы уверены?",
yes: "Да",
no: "Нет",
orderNumber: "№",
}
const handleCancelOrder = (orderId: number) => {
setSelectedOrderId(orderId)
setIsDeleteModalOpen(true)
}
const confirmCancelOrder = async () => {
if (selectedOrderId) {
console.log("Canceling order:", selectedOrderId)
setIsDeleteModalOpen(false)
setSelectedOrderId(null)
}
}
return (
<div className="container mx-auto px-4 py-8 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.orders}</h1>
{/* Orders content */}
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useCallback, useMemo } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
@@ -17,112 +17,90 @@ import {
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { useOrders, useCancelOrder } from "@/lib/hooks";
import type { Order } from "../types";
import { useTranslations } from "next-intl";
import type { Order } from "@/lib/types/api";
export default function OrdersPageClient() {
interface OrdersPageClientProps {
locale: string;
}
export default function OrdersPageClient({ locale }: OrdersPageClientProps) {
const [isCancelDialogOpen, setIsCancelDialogOpen] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<Order | null>(null);
const { toast } = useToast();
const t = useTranslations();
const { data: orders, isLoading, isError, error } = useOrders();
const { mutate: cancelOrder, isPending: isCancellingOrder } = useCancelOrder();
const t = {
myOrders: "Мои заказы",
activeOrders: "Активные заказы",
completedOrders: "Завершенные заказы",
cancelOrder: "Отменить заказ",
keepOrder: "Оставить заказ",
cancelConfirmation: "Вы уверены, что хотите отменить этот заказ?",
cancelling: "Отмена...",
orderNumber: "Заказ №",
ordered: "Заказано",
completed: "Завершено",
estimatedDelivery: "Ожид. доставка",
quantity: "Кол-во",
total: "Итого",
noOrders: "У вас пока нет заказов",
noActiveOrders: "У вас нет активных заказов",
noCompletedOrders: "У вас нет завершенных заказов",
loadError: "Не удалось загрузить заказы",
orderCancelled: "Заказ отменен",
orderCancelledDescription: "Ваш заказ был успешно отменен",
error: "Ошибка",
status: "Статус",
deliveryTime: "Время доставки",
deliveryDate: "Дата доставки",
address: "Адрес",
paymentMethod: "Способ оплаты",
};
const handleCancelOrder = (order: Order) => {
const handleCancelOrder = useCallback((order: Order) => {
setOrderToCancel(order);
setIsCancelDialogOpen(true);
};
}, []);
const confirmCancelOrder = () => {
const confirmCancelOrder = useCallback(() => {
if (!orderToCancel) return;
cancelOrder(orderToCancel.id, {
onSuccess: () => {
toast({
title: t.orderCancelled,
description: t.orderCancelledDescription,
title: t("order_cancelled"),
description: t("order_cancelled_description"),
});
setIsCancelDialogOpen(false);
setOrderToCancel(null);
},
onError: (error: any) => {
toast({
title: t.error,
description: error.message || "Не удалось отменить заказ",
title: t("error"),
description: error.message || t("cancel_order_failed"),
variant: "destructive",
});
},
});
};
}, [orderToCancel, cancelOrder, toast, t]);
const getStatusBadge = (status: string) => {
const getStatusBadge = useCallback((status: string) => {
const lowerStatus = status.toLowerCase();
if (lowerStatus.includes("ожидается") || lowerStatus.includes("pending")) {
if (lowerStatus.includes("ожидается") || lowerStatus.includes("pending") || lowerStatus.includes("garaşlama")) {
return <Badge variant="outline">{status}</Badge>;
}
if (lowerStatus.includes("обработка") || lowerStatus.includes("processing")) {
if (lowerStatus.includes("обработка") || lowerStatus.includes("processing") || lowerStatus.includes("işlenýär")) {
return <Badge variant="secondary">{status}</Badge>;
}
if (lowerStatus.includes("отправлен") || lowerStatus.includes("shipped")) {
if (lowerStatus.includes("отправлен") || lowerStatus.includes("shipped") || lowerStatus.includes("iberildi")) {
return <Badge>{status}</Badge>;
}
if (lowerStatus.includes("доставлен") || lowerStatus.includes("delivered")) {
if (lowerStatus.includes("доставлен") || lowerStatus.includes("delivered") || lowerStatus.includes("eltildi")) {
return <Badge className="bg-green-600">{status}</Badge>;
}
if (lowerStatus.includes("отменен") || lowerStatus.includes("cancelled")) {
if (lowerStatus.includes("отменен") || lowerStatus.includes("cancelled") || lowerStatus.includes("ýatyryldy")) {
return <Badge variant="destructive">{status}</Badge>;
}
return <Badge>{status}</Badge>;
};
}, []);
const isActiveOrder = (status: string) => {
const isActiveOrder = useCallback((status: string) => {
const lower = status.toLowerCase();
return lower.includes("ожидается") || lower.includes("обработка") || lower.includes("отправлен") ||
lower.includes("pending") || lower.includes("processing") || lower.includes("shipped");
};
lower.includes("pending") || lower.includes("processing") || lower.includes("shipped") ||
lower.includes("garaşlama") || lower.includes("işlenýär") || lower.includes("iberildi");
}, []);
const activeOrders = orders?.filter((o) => isActiveOrder(o.status)) || [];
const completedOrders = orders?.filter((o) => !isActiveOrder(o.status)) || [];
const activeOrders = useMemo(() => orders?.filter((o) => isActiveOrder(o.status)) || [], [orders, isActiveOrder]);
const completedOrders = useMemo(() => orders?.filter((o) => !isActiveOrder(o.status)) || [], [orders, isActiveOrder]);
const calculateTotal = (order: Order) => {
const calculateTotal = useCallback((order: Order) => {
return order.orderItems.reduce((sum, item) => {
return sum + (parseFloat(item.unit_price_amount) * item.quantity);
}, 0);
};
}, []);
if (isLoading) {
return (
const loadingSkeleton = useMemo(() => (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<div className="space-y-4">
<Skeleton className="h-10 w-40" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -132,15 +110,18 @@ export default function OrdersPageClient() {
</div>
</div>
</div>
);
), [t]);
if (isLoading) {
return loadingSkeleton;
}
if (isError) {
return (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
<p className="text-red-600">{t.loadError}</p>
<p className="text-red-600">{t("load_orders_error")}</p>
</div>
</div>
);
@@ -149,9 +130,9 @@ export default function OrdersPageClient() {
if (!orders || orders.length === 0) {
return (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<div className="flex items-center justify-center min-h-[60vh]">
<p className="text-2xl text-gray-400">{t.noOrders}</p>
<p className="text-2xl text-gray-400">{t("no_orders")}</p>
</div>
</div>
);
@@ -159,22 +140,22 @@ export default function OrdersPageClient() {
return (
<div className="container mx-auto p-4 min-h-screen">
<h1 className="text-3xl font-bold mb-6">{t.myOrders}</h1>
<h1 className="text-3xl font-bold mb-6">{t("my_orders")}</h1>
<Tabs defaultValue="active" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="active">
{t.activeOrders} ({activeOrders.length})
{t("active_orders")} ({activeOrders.length})
</TabsTrigger>
<TabsTrigger value="completed">
{t.completedOrders} ({completedOrders.length})
{t("completed_orders")} ({completedOrders.length})
</TabsTrigger>
</TabsList>
<TabsContent value="active">
{activeOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-xl text-gray-400">{t.noActiveOrders}</p>
<p className="text-xl text-gray-400">{t("no_active_orders")}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -186,7 +167,6 @@ export default function OrdersPageClient() {
isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal}
translations={t}
showCancelButton
/>
))}
@@ -197,7 +177,7 @@ export default function OrdersPageClient() {
<TabsContent value="completed">
{completedOrders.length === 0 ? (
<div className="flex items-center justify-center min-h-[40vh]">
<p className="text-xl text-gray-400">{t.noCompletedOrders}</p>
<p className="text-xl text-gray-400">{t("no_completed_orders")}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -209,7 +189,6 @@ export default function OrdersPageClient() {
isCancelling={isCancellingOrder}
getStatusBadge={getStatusBadge}
calculateTotal={calculateTotal}
translations={t}
showCancelButton={false}
/>
))}
@@ -222,9 +201,9 @@ export default function OrdersPageClient() {
<DialogContent>
<DialogHeader>
<DialogTitle>
{t.cancelOrder} #{orderToCancel?.id}
{t("cancel_order")} #{orderToCancel?.id}
</DialogTitle>
<DialogDescription>{t.cancelConfirmation}</DialogDescription>
<DialogDescription>{t("cancel_confirmation")}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
@@ -232,10 +211,10 @@ export default function OrdersPageClient() {
onClick={() => setIsCancelDialogOpen(false)}
disabled={isCancellingOrder}
>
{t.keepOrder}
{t("keep_order")}
</Button>
<Button variant="destructive" onClick={confirmCancelOrder} disabled={isCancellingOrder}>
{isCancellingOrder ? t.cancelling : t.cancelOrder}
{isCancellingOrder ? t("cancelling") : t("cancel_order")}
</Button>
</DialogFooter>
</DialogContent>
@@ -250,7 +229,6 @@ interface OrderCardProps {
isCancelling: boolean;
getStatusBadge: (status: string) => React.ReactNode;
calculateTotal: (order: Order) => number;
translations: any;
showCancelButton: boolean;
}
@@ -260,34 +238,34 @@ function OrderCard({
isCancelling,
getStatusBadge,
calculateTotal,
translations: t,
showCancelButton,
}: OrderCardProps) {
const total = calculateTotal(order);
const t = useTranslations();
const total = useMemo(() => calculateTotal(order), [calculateTotal, order]);
return (
<Card className="p-4 flex flex-col justify-between">
<div>
<div className="flex justify-between items-center mb-3">
<h3 className="text-lg font-semibold">
{t.orderNumber}{order.id}
{t("order_number")}{order.id}
</h3>
{getStatusBadge(order.status)}
</div>
<div className="mb-3 space-y-1 text-sm">
<p className="text-gray-600">
<span className="font-medium">{t.deliveryTime}:</span> {order.delivery_time}
<span className="font-medium">{t("delivery_time")}:</span> {order.delivery_time}
</p>
<p className="text-gray-600">
<span className="font-medium">{t.deliveryDate}:</span>{" "}
<span className="font-medium">{t("delivery_date")}:</span>{" "}
{new Date(order.delivery_at).toLocaleDateString()}
</p>
<p className="text-gray-600">
<span className="font-medium">{t.address}:</span> {order.customer_address}
<span className="font-medium">{t("address")}:</span> {order.customer_address}
</p>
<p className="text-gray-600">
<span className="font-medium">{t.paymentMethod}:</span> {order.payment_type}
<span className="font-medium">{t("payment_method")}:</span> {order.payment_type}
</p>
</div>
@@ -304,7 +282,7 @@ function OrderCard({
<div className="flex-1 min-w-0">
<p className="text-sm font-medium line-clamp-2">{item.product.name}</p>
<p className="text-xs text-gray-500">
{t.quantity}: {item.quantity} × {item.unit_price_amount} TMT
{t("product_quantity")}: {item.quantity} × {item.unit_price_amount} TMT
</p>
</div>
</div>
@@ -313,7 +291,7 @@ function OrderCard({
<div className="border-t pt-3">
<div className="flex justify-between font-semibold">
<span>{t.total}</span>
<span>{t("total_price")}</span>
<span>{total.toFixed(2)} TMT</span>
</div>
</div>
@@ -327,7 +305,7 @@ function OrderCard({
disabled={isCancelling}
className="w-full"
>
{t.cancelOrder}
{t("cancel_order")}
</Button>
</div>
)}

View File

@@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { Order, OrdersResponse, CreateOrderRequest } from "../types";
import type { Order, OrdersResponse, CreateOrderRequest } from "@/lib/types/api";
export function useOrders(options?: { page?: number; perPage?: number }) {
return useQuery<Order[]>({

View File

@@ -1,59 +0,0 @@
export interface OrderProduct {
id: number;
name: string;
thumbnail: string;
images_400x400: string;
images_800x800: string;
images_1200x1200: string;
}
export interface OrderItem {
product: OrderProduct;
order: {
id: number;
};
quantity: number;
unit_price_amount: string;
}
export interface Order {
id: number;
status: string;
shipping_method: string;
notes: string | null;
customer_name: string;
customer_phone: string;
customer_address: string;
delivery_time: string;
delivery_at: string;
region: string;
user_id: number;
province_id: number | null;
payment_type: string;
orderItems: OrderItem[];
}
export interface OrdersResponse {
message: string;
data: Order[];
pagination: {
page: number;
perPage: number;
count: number;
first_page_url: string;
next_page_url: string | null;
prev_page_url: string | null;
};
}
export interface CreateOrderRequest {
customer_name: string;
customer_phone: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}

View File

@@ -1,113 +1,301 @@
"use client"
"use client";
import { useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { Minus, Plus, Heart, ShoppingCart, Store } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Separator } from "@/components/ui/separator"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Skeleton } from "@/components/ui/skeleton"
import { useProductsBySlug } from "@/features/products/hooks/useProducts"
import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/features/cart/hooks/useCart"
import { toast } from "sonner"
import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import Image from "next/image";
import Link from "next/link";
import { Minus, Plus, Heart, ShoppingCart, Store, Loader2, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useProductsBySlug } from "@/features/products/hooks/useProducts";
import { useAddToCart, useUpdateCartItemQuantity, useCart } from "@/features/cart/hooks/useCart";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
interface ProductDetailProps {
slug: string
slug: string;
}
const ProductPageContent = ({ slug }: ProductDetailProps) => {
const [selectedImage, setSelectedImage] = useState(0)
const [quantity, setQuantity] = useState(1)
const [isFavorite, setIsFavorite] = useState(false)
const PENDING_PRODUCT_UPDATES_KEY = 'pendingProductUpdates';
// Get product data
const { data: product, isLoading: productLoading, error } = useProductsBySlug(slug)
// Get cart data to check if product is already in cart
const { data: cartData } = useCart()
// Cart mutations
const addToCartMutation = useAddToCart()
const updateCartMutation = useUpdateCartItemQuantity()
const t = {
addToCart: "Sebede goş",
goToCart: "Sebede git",
price: "Bahasy:",
aboutProduct: "Haryt barada",
brand: "Marka",
stock: "Mukdary",
description: "Düşündiriş",
store: "Dükan",
writeToStore: "Dükana ýaz",
color: "Reňk:",
category: "Kategoriýa:",
barcode: "Barkod:",
addedToCart: "Sebede goşuldy",
updatedCart: "Sebe täzelendi",
error: "Ýalňyşlyk ýüze çykdy",
interface PendingUpdate {
quantity: number;
timestamp: number;
retryCount: number;
}
// Check if product is in cart
const cartItem = cartData?.data?.find((item: any) => item.product?.id === product?.id)
const isInCart = !!cartItem
export default function ProductPageContent({ slug }: ProductDetailProps) {
const [selectedImage, setSelectedImage] = useState(0);
const [localQuantity, setLocalQuantity] = useState(1);
const [isFavorite, setIsFavorite] = useState(false);
const [isSyncing, setIsSyncing] = useState(false);
const [syncError, setSyncError] = useState(false);
const [showStockModal, setShowStockModal] = useState(false);
const handleAddToCart = async () => {
if (!product?.id) return
const t = useTranslations();
const debounceTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const isRequestInFlightRef = useRef(false);
const pendingQuantityRef = useRef<number | null>(null);
const retryCountRef = useRef(0);
const retryTimerRef = useRef<NodeJS.Timeout | undefined>(undefined);
const syncToServerRef = useRef<((quantity: number) => void) | null>(null);
const retrySyncRef = useRef<((quantity: number) => void) | null>(null);
const { data: product, isLoading: productLoading, error } = useProductsBySlug(slug);
const { data: cartData, refetch: refetchCart } = useCart();
const addToCartMutation = useAddToCart();
const updateCartMutation = useUpdateCartItemQuantity();
const cartItem = useMemo(() =>
cartData?.data?.find((item: any) => item.product?.id === product?.id),
[cartData, product]
);
const isInCart = !!cartItem;
const availableStock = product?.stock || 0;
useEffect(() => {
if (cartItem?.product_quantity) {
setLocalQuantity(cartItem.product_quantity);
}
}, [cartItem]);
const savePendingUpdate = useCallback((quantity: number) => {
if (!product?.id) return;
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
const pending: Record<number, PendingUpdate> = stored ? JSON.parse(stored) : {};
pending[product.id] = {
quantity,
timestamp: Date.now(),
retryCount: retryCountRef.current
};
sessionStorage.setItem(PENDING_PRODUCT_UPDATES_KEY, JSON.stringify(pending));
} catch (error) {
console.error('Failed to save pending update:', error);
}
}, [product?.id]);
const clearPendingUpdate = useCallback(() => {
if (!product?.id) return;
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
delete pending[product.id];
if (Object.keys(pending).length === 0) {
sessionStorage.removeItem(PENDING_PRODUCT_UPDATES_KEY);
} else {
sessionStorage.setItem(PENDING_PRODUCT_UPDATES_KEY, JSON.stringify(pending));
}
}
} catch (error) {
console.error('Failed to clear pending update:', error);
}
}, [product?.id]);
const retrySync = useCallback((quantity: number) => {
const maxRetries = 4;
const retryCount = retryCountRef.current;
if (retryCount >= maxRetries) {
setSyncError(true);
setIsSyncing(false);
toast.error(t("error"), {
description: t("update_quantity_failed"),
});
return;
}
const delay = Math.min(1000 * Math.pow(2, retryCount), 16000);
retryCountRef.current++;
retryTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(quantity);
}, delay);
}, [t]);
retrySyncRef.current = retrySync;
const syncToServer = useCallback(async (quantity: number) => {
if (!product?.id) return;
if (isRequestInFlightRef.current) {
pendingQuantityRef.current = quantity;
return;
}
isRequestInFlightRef.current = true;
setIsSyncing(true);
setSyncError(false);
try {
if (isInCart) {
await updateCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
} else {
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
});
}
isRequestInFlightRef.current = false;
setIsSyncing(false);
retryCountRef.current = 0;
clearPendingUpdate();
// Refetch cart to update UI state immediately
await refetchCart();
if (pendingQuantityRef.current !== null) {
const nextQuantity = pendingQuantityRef.current;
pendingQuantityRef.current = null;
setTimeout(() => syncToServerRef.current?.(nextQuantity), 100);
}
} catch (error) {
console.error('Sync failed:', error);
isRequestInFlightRef.current = false;
if (retryCountRef.current >= 3) {
setLocalQuantity(cartItem?.product_quantity || 1);
clearPendingUpdate();
}
retrySyncRef.current?.(quantity);
}
}, [product?.id, isInCart, updateCartMutation, addToCartMutation, cartItem, clearPendingUpdate, refetchCart]);
syncToServerRef.current = syncToServer;
useEffect(() => {
if (!product?.id) return;
const loadPendingUpdates = () => {
try {
const stored = sessionStorage.getItem(PENDING_PRODUCT_UPDATES_KEY);
if (stored) {
const pending: Record<number, PendingUpdate> = JSON.parse(stored);
const productPending = pending[product.id];
if (productPending && productPending.quantity !== (cartItem?.product_quantity || 1)) {
setLocalQuantity(productPending.quantity);
pendingQuantityRef.current = productPending.quantity;
retryCountRef.current = productPending.retryCount;
setTimeout(() => syncToServerRef.current?.(productPending.quantity), 500);
}
}
} catch (error) {
console.error('Failed to load pending updates:', error);
}
};
loadPendingUpdates();
}, [product?.id, cartItem]);
useEffect(() => {
if (!isInCart || !product?.id) return;
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (localQuantity === (cartItem?.product_quantity || 1)) {
return;
}
savePendingUpdate(localQuantity);
debounceTimerRef.current = setTimeout(() => {
syncToServerRef.current?.(localQuantity);
}, 800);
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
};
}, [localQuantity, isInCart, product?.id, cartItem, savePendingUpdate]);
useEffect(() => {
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
};
}, []);
const handleAddToCart = useCallback(async () => {
if (!product?.id) return;
// Set syncing state immediately for UI feedback
setIsSyncing(true);
try {
await addToCartMutation.mutateAsync({
productId: product.id,
quantity: quantity,
})
quantity: localQuantity,
});
toast.success(t.addedToCart, {
description: `${product.name} sebede goşuldy`,
})
// Refetch cart immediately to update isInCart state
await refetchCart();
setIsSyncing(false);
toast.success(t("added_to_cart"), {
description: `${product.name} ${t("added_to_cart_description")}`,
});
} catch (error) {
console.error("Add to cart error:", error)
toast.error(t.error, {
description: "Haryt sebede goşup bolmady",
})
console.error("Add to cart error:", error);
setIsSyncing(false);
toast.error(t("error"), {
description: t("add_to_cart_failed"),
});
}
}, [product, localQuantity, addToCartMutation, refetchCart, t]);
const handleQuantityIncrease = useCallback(() => {
if (localQuantity >= availableStock) {
setShowStockModal(true);
return;
}
const handleQuantityChange = async (newQuantity: number) => {
if (newQuantity < 1 || !product?.id) return
if (newQuantity > product.stock) return
setLocalQuantity(prev => prev + 1);
}, [localQuantity, availableStock]);
setQuantity(newQuantity)
const handleQuantityDecrease = useCallback(() => {
if (localQuantity <= 1) return;
// If product is already in cart, update it
if (isInCart) {
try {
await updateCartMutation.mutateAsync({
productId: product.id,
quantity: newQuantity,
})
setLocalQuantity(prev => prev - 1);
}, [localQuantity]);
toast.success(t.updatedCart, {
description: `Mukdar: ${newQuantity}`,
})
} catch (error) {
console.error("Update cart error:", error)
toast.error(t.error, {
description: "Mukdar täzelenip bolmady",
})
}
}
}
const handleToggleFavorite = useCallback(() => {
setIsFavorite(!isFavorite);
}, [isFavorite]);
const handleToggleFavorite = () => {
setIsFavorite(!isFavorite)
// TODO: Implement favorites API
}
const imageUrls = useMemo(() =>
product?.media?.map(m => m.images_800x800 || m.images_720x720 || m.thumbnail) || [],
[product]
);
// Loading state
if (productLoading) {
return (
const loadingSkeleton = useMemo(() => (
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
<div className="flex-1 max-w-2xl">
@@ -124,28 +312,25 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
</div>
</div>
</div>
)
), []);
if (productLoading) {
return loadingSkeleton;
}
// Error state
if (error || !product) {
return (
<div className="container mx-auto px-4 py-8 text-center">
<h2 className="text-2xl font-bold text-red-600">Haryt tapylmady</h2>
<p className="text-gray-500 mt-2">Bu haryt ýok ýa-da aýryldy</p>
<h2 className="text-2xl font-bold text-red-600">{t("product_not_found")}</h2>
<p className="text-gray-500 mt-2">{t("product_not_found_description")}</p>
</div>
)
);
}
// Extract image URLs from media array
const imageUrls = product.media?.map(m => m.images_800x800 || m.images_720x720 || m.thumbnail) || []
const isLoading = addToCartMutation.isPending || updateCartMutation.isPending
return (
<>
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* Product Images */}
<div className="flex-1 max-w-2xl">
<div className="relative">
<div className="relative aspect-square w-full rounded-2xl overflow-hidden bg-gray-50">
@@ -159,12 +344,11 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
Surat ýok
{t("no_image")}
</div>
)}
</div>
{/* Thumbnail Images */}
{imageUrls.length > 1 && (
<div className="mt-4 flex gap-2 overflow-x-auto pb-2">
{imageUrls.map((image, index) => (
@@ -190,7 +374,6 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
</div>
</div>
{/* Product Info */}
<div className="flex-1 space-y-6">
<div>
<h1 className="text-3xl font-bold mb-2">{product.name}</h1>
@@ -208,14 +391,13 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
)}
</div>
{/* Product Info Table */}
<Card className="p-4 rounded-xl border-gray-200">
<h3 className="text-xl font-semibold mb-4">{t.aboutProduct}</h3>
<h3 className="text-xl font-semibold mb-4">{t("about_product")}</h3>
<div className="space-y-3">
{product.brand?.name && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.brand}</span>
<span className="text-gray-500">{t("brand")}</span>
<span className="font-medium">{product.brand.name}</span>
</div>
<Separator />
@@ -225,9 +407,9 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
{product.stock !== undefined && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.stock}</span>
<span className={`font-medium ${product.stock === 0 ? 'text-red-500' : 'text-green-600'}`}>
{product.stock === 0 ? 'Ýok' : product.stock}
<span className="text-gray-500">{t("stock")}</span>
<span className={`font-medium ${product.stock === 0 ? 'text-red-500' : product.stock <= 5 ? 'text-orange-600' : 'text-green-600'}`}>
{product.stock === 0 ? t("out_of_stock") : product.stock <= 5 ? `${t("only_left", { count: product.stock })}` : product.stock}
</span>
</div>
<Separator />
@@ -237,7 +419,7 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
{product.barcode && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.barcode}</span>
<span className="text-gray-500">{t("barcode")}</span>
<span className="font-mono text-sm">{product.barcode}</span>
</div>
<Separator />
@@ -247,7 +429,7 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
{product.colour && (
<>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">{t.color}</span>
<span className="text-gray-500">{t("color")}</span>
<span className="font-medium">{product.colour}</span>
</div>
<Separator />
@@ -272,10 +454,9 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
</div>
</Card>
{/* Description */}
{product.description && (
<Card className="p-4 rounded-xl border-gray-200">
<h3 className="text-xl font-semibold mb-3">{t.description}</h3>
<h3 className="text-xl font-semibold mb-3">{t("product_description")}</h3>
<div
className="text-gray-700 leading-relaxed prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: product.description }}
@@ -284,11 +465,10 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
)}
</div>
{/* Price & Actions Sidebar */}
<div className="lg:w-[380px] space-y-4">
<Card className="p-6 rounded-xl shadow-lg sticky top-4">
<div className="flex justify-between items-start mb-6">
<span className="text-lg text-gray-500">{t.price}</span>
<span className="text-lg text-gray-500">{t("price")}:</span>
<div className="flex flex-col items-end">
<span className="text-3xl font-bold text-primary">
{product.price_amount} TMT
@@ -310,7 +490,7 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
className="w-full rounded-xl text-lg font-bold bg-green-600 hover:bg-green-700"
>
<ShoppingCart className="mr-2 h-5 w-5" />
{t.goToCart}
{t("go_to_cart")}
</Button>
</Link>
@@ -318,21 +498,29 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
<Button
variant="outline"
size="icon"
onClick={() => handleQuantityChange(quantity - 1)}
disabled={quantity === 1 || isLoading}
className="rounded-xl h-12 w-12"
onClick={handleQuantityDecrease}
disabled={localQuantity === 1 || isSyncing}
className={`rounded-xl h-12 w-12 ${isSyncing ? 'opacity-70' : ''}`}
>
<Minus className="h-5 w-5" />
</Button>
<div className="flex-1 text-center font-semibold text-xl border rounded-xl h-12 flex items-center justify-center">
{quantity}
<div className="flex-1 text-center font-semibold text-xl border rounded-xl h-12 flex items-center justify-center relative">
{localQuantity}
{isSyncing && (
<Loader2 className="h-4 w-4 animate-spin absolute -top-1 -right-1 text-blue-500" />
)}
{syncError && (
<span className="absolute -top-1 -right-1 h-2 w-2 bg-red-500 rounded-full" title="Sync error" />
)}
</div>
<Button
variant="outline"
size="icon"
onClick={() => handleQuantityChange(quantity + 1)}
disabled={isLoading || quantity >= product.stock}
className="rounded-xl h-12 w-12"
onClick={handleQuantityIncrease}
disabled={localQuantity >= availableStock || isSyncing}
className={`rounded-xl h-12 w-12 ${isSyncing ? 'opacity-70' : ''} ${
localQuantity >= availableStock ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<Plus className="h-5 w-5" />
</Button>
@@ -342,11 +530,20 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
<Button
size="lg"
onClick={handleAddToCart}
disabled={isLoading || product.stock === 0}
disabled={isSyncing || product.stock === 0}
className="w-full rounded-xl text-lg font-bold"
>
{isSyncing ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
{t("adding")}
</>
) : (
<>
<ShoppingCart className="mr-2 h-5 w-5" />
{isLoading ? "Goşulýar..." : product.stock === 0 ? "Haryt ýok" : t.addToCart}
{product.stock === 0 ? t("out_of_stock") : t("add_to_cart")}
</>
)}
</Button>
)}
@@ -369,7 +566,6 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
</div>
</Card>
{/* Store/Channel Card */}
{product.channel && product.channel.length > 0 && (
<Card className="p-6 rounded-xl">
<div className="flex items-center gap-4 mb-4">
@@ -379,7 +575,7 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
</AvatarFallback>
</Avatar>
<div>
<p className="text-sm text-gray-500">{t.store}</p>
<p className="text-sm text-gray-500">{t("store")}</p>
<h4 className="text-lg font-bold">{product.channel[0].name}</h4>
</div>
</div>
@@ -388,14 +584,42 @@ const ProductPageContent = ({ slug }: ProductDetailProps) => {
size="lg"
className="w-full rounded-xl"
>
{t.writeToStore}
{t("write_to_store")}
</Button>
</Card>
)}
</div>
</div>
</div>
)
}
export default ProductPageContent
<Dialog open={showStockModal} onOpenChange={setShowStockModal}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full bg-orange-100 p-3">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
<DialogTitle className="text-center text-xl">
{t("stock_limit_title")}
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
{t("stock_limit_message", {
product: product.name,
stock: availableStock
})}
</DialogDescription>
</DialogHeader>
<div className="flex justify-center mt-4">
<Button
onClick={() => setShowStockModal(false)}
className="w-full rounded-xl"
>
{t("understood")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -1,9 +0,0 @@
"use client"
export default function ProductPageContent({ slug }: { slug: string }) {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold">Product: {slug}</h1>
{/* Product content will go here */}
</div>
)
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback, useMemo } from "react";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Skeleton } from "@/components/ui/skeleton";
import { useUserProfile } from "@/lib/hooks";
import { clearAuthToken } from "@/lib/api";
import { useTranslations } from "next-intl";
interface ProfilePageProps {
params: Promise<{ locale: string }>;
@@ -15,28 +17,14 @@ interface ProfilePageProps {
export default function ClientProfilePage(props: ProfilePageProps) {
const { data: user, isLoading, error } = useUserProfile();
const t = useTranslations();
const translations = {
profile: "Профиль",
personalInfo: "Личная информация",
profileDescription: "Ваши данные профиля",
firstName: "Имя",
lastName: "Фамилия",
phone: "Номер телефона",
address: "Адрес",
logout: "Выйти",
loading: "Загрузка...",
errorLoading: "Не удалось загрузить профиль",
tryAgain: "Попробовать снова",
};
const handleLogout = () => {
const handleLogout = useCallback(() => {
clearAuthToken();
window.location.href = "/";
};
}, []);
if (isLoading) {
return (
const loadingSkeleton = useMemo(() => (
<div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl">
<Skeleton className="h-10 w-48 mb-6" />
@@ -56,7 +44,10 @@ export default function ClientProfilePage(props: ProfilePageProps) {
</Card>
</div>
</div>
);
), []);
if (isLoading) {
return loadingSkeleton;
}
if (error) {
@@ -64,8 +55,8 @@ export default function ClientProfilePage(props: ProfilePageProps) {
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardContent className="pt-6 text-center">
<p className="text-red-600 mb-4">{translations.errorLoading}</p>
<Button onClick={() => window.location.reload()}>{translations.tryAgain}</Button>
<p className="text-red-600 mb-4">{t("error_loading_profile")}</p>
<Button onClick={() => window.location.reload()}>{t("try_again")}</Button>
</CardContent>
</Card>
</div>
@@ -75,33 +66,33 @@ export default function ClientProfilePage(props: ProfilePageProps) {
return (
<div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl">
<h1 className="text-3xl font-bold mb-6">{translations.profile}</h1>
<h1 className="text-3xl font-bold mb-6">{t("profile")}</h1>
<Card className="shadow-lg mb-4">
<CardHeader>
<CardTitle>{translations.personalInfo}</CardTitle>
<CardDescription>{translations.profileDescription}</CardDescription>
<CardTitle>{t("personal_info")}</CardTitle>
<CardDescription>{t("profile_description")}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{user && (
<>
<div className="space-y-2">
<Label htmlFor="firstName">{translations.firstName}</Label>
<Label htmlFor="firstName">{t("first_name")}</Label>
<Input id="firstName" value={user.first_name || ""} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">{translations.lastName}</Label>
<Label htmlFor="lastName">{t("last_name")}</Label>
<Input id="lastName" value={user.last_name || ""} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">{translations.phone}</Label>
<Label htmlFor="phone">{t("phone_number")}</Label>
<Input id="phone" value={user.phone_number || ""} disabled className="bg-gray-50" />
</div>
<div className="space-y-2">
<Label htmlFor="address">{translations.address}</Label>
<Label htmlFor="address">{t("address")}</Label>
<Input id="address" value={user.address || ""} disabled className="bg-gray-50" />
</div>
</>
@@ -116,7 +107,7 @@ export default function ClientProfilePage(props: ProfilePageProps) {
className="w-full max-w-md flex items-center justify-center gap-2"
>
<LogOut className="h-5 w-5" />
{translations.logout}
{t("common.logout")}
</Button>
</div>
</div>

View File

@@ -1,66 +0,0 @@
"use client"
import { useState, useEffect } from "react"
interface User {
first_name: string
last_name: string
phone: string
email?: string
}
interface ProfileContentProps {
locale: string
}
export default function ProfilePageContent({ locale }: ProfileContentProps) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const t = {
profile: "Профиль",
firstName: "Имя",
lastName: "Фамилия",
phone: "Номер телефона",
email: "Email",
logout: "Выйти",
loading: "Загрузка...",
}
useEffect(() => {
const fetchUserData = () => {
setTimeout(() => {
setUser({
first_name: "Иван",
last_name: "Иванов",
phone: "+99361234567",
email: "ivan@example.com",
})
setLoading(false)
}, 500)
}
fetchUserData()
}, [])
const handleLogout = () => {
window.location.href = "/"
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<p className="text-lg text-gray-600">{t.loading}</p>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 p-4 pt-20">
<div className="container mx-auto max-w-2xl">
<h1 className="text-3xl font-bold mb-6">{t.profile}</h1>
{/* Profile content */}
</div>
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import { userStore } from "../userStore";
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "../types";
import type { ProfileResponse, UpdateProfileRequest, UpdateProfileResponse } from "@/lib/types/api";
export const useUserProfile = () => {
return useQuery<ProfileResponse["data"]>({

View File

@@ -1,23 +0,0 @@
export interface UserProfile {
first_name: string;
last_name: string;
phone_number: string;
address: string;
}
export interface ProfileResponse {
message: string;
data: UserProfile;
}
export interface UpdateProfileRequest {
first_name?: string;
last_name?: string;
phone_number?: string;
address?: string;
}
export interface UpdateProfileResponse {
message: string;
data: UserProfile;
}

View File

@@ -0,0 +1,30 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/lib/api";
import type { SearchResponse, SearchParams } from "../types";
export function useSearchProducts(params: SearchParams) {
const { q, barcode } = params;
return useQuery({
queryKey: ["search", { q, barcode }],
queryFn: async () => {
if (barcode) {
const response = await apiClient.get<SearchResponse>(
`/search-product-barcode?barcode=${barcode}`
);
return response.data;
}
if (q) {
const response = await apiClient.get<SearchResponse>(
`/search-product?q=${encodeURIComponent(q)}`
);
return response.data;
}
return { message: "success", data: [] };
},
enabled: !!(q && q.length > 0) || !!barcode,
staleTime: 1000 * 60 * 5,
});
}

30
features/search/types.ts Normal file
View File

@@ -0,0 +1,30 @@
// Search Types
export interface SearchProduct {
id: number;
name: string;
stock: number;
cost_amount: string;
price_amount: string;
brand: {
id: number;
name: string;
};
thumbnail: string;
media: Array<{
thumbnail: string;
images_400x400: string;
images_720x720: string;
images_800x800: string;
images_1200x1200: string;
}>;
}
export interface SearchResponse {
message: string;
data: SearchProduct[];
}
export interface SearchParams {
q?: string;
barcode?: string;
}

View File

@@ -15,7 +15,8 @@
"code": "Код",
"send": "Отправить",
"enterPhone": "Введите свой номер телефона",
"weWillSendCode": "Мы вышлем вам код"
"weWillSendCode": "Мы вышлем вам код",
"loading": "Загрузка..."
},
"category": "Категория",
"checkout": "Оформить заказ",
@@ -85,5 +86,68 @@
"seller_application_form": "Форма подачи заявления на открытие магазина",
"phone": "Телефон",
"unit_price": "Цена за 1 шт.:",
"order_available_in_shops": "Имеется заказ в магазинах:"
"order_available_in_shops": "Имеется заказ в магазинах:",
"subcategories": "Подкатегории",
"sort": "Сортировка",
"default": "По умолчанию",
"price_low_to_high": "От дешевых к дорогим",
"price_high_to_low": "От дорогих к дешевым",
"reset": "Сбросить",
"total": "Всего",
"no_results": "Результатов не найдено",
"close": "Закрыть",
"category_not_found": "Категория не найдена",
"empty_favorites": "У вас пока нет избранных товаров",
"removed_from_favorites": "Товар удален из избранного",
"added_to_cart": "Товар добавлен в корзину",
"error": "Произошла ошибка",
"out_of_stock": "Нет в наличии",
"personal_info": "Личная информация",
"profile_description": "Ваши данные профиля",
"error_loading_profile": "Не удалось загрузить профиль",
"try_again": "Попробовать снова",
"my_orders": "Мои заказы",
"active_orders": "Активные заказы",
"completed_orders": "Завершенные заказы",
"cancel_order": "Отменить заказ",
"keep_order": "Оставить заказ",
"cancel_confirmation": "Вы уверены, что хотите отменить этот заказ?",
"cancelling": "Отмена...",
"order_number": "Заказ №",
"no_orders": "У вас пока нет заказов",
"no_active_orders": "У вас нет активных заказов",
"no_completed_orders": "У вас нет завершенных заказов",
"load_orders_error": "Не удалось загрузить заказы",
"order_cancelled": "Заказ отменен",
"order_cancelled_description": "Ваш заказ был успешно отменен",
"cancel_order_failed": "Не удалось отменить заказ",
"delivery_time": "Время доставки",
"delivery_date": "Дата доставки",
"payment_method": "Способ оплаты",
"product_not_found": "Товар не найден",
"product_not_found_description": "Этот товар не существует или был удален",
"no_image": "Нет изображения",
"stock": "Наличие",
"barcode": "Штрих-код",
"product_description": "Описание товара",
"adding": "Добавление...",
"added_to_cart_description": "добавлен в корзину",
"add_to_cart_failed": "Не удалось добавить товар в корзину",
"cart_updated": "Корзина обновлена",
"update_quantity_failed": "Не удалось обновить количество",
"logging_out": "Выход...",
"invalid_phone": "Неверный номер телефона",
"invalid_code": "Неверный код",
"code_sent": "Код отправлен на ваш номер",
"login_success": "Вход выполнен успешно",
"error_occurred": "Произошла ошибка",
"wrong_code": "Неверный код",
"phone_format": "Формат: 99365123456",
"sending": "Отправка...",
"verifying": "Проверка...",
"verify": "Подтвердить",
"only_left": "Sadece {count} adet kaldı",
"stock_limit_title": "Stok Limiti",
"stock_limit_message": "{product} ürününden sadece {stock} adet mevcut. Daha fazla ekleyemezsiniz.",
"understood": "Anladım"
}

View File

@@ -15,7 +15,8 @@
"code": "Kod",
"send": "Ugrat",
"enterPhone": "Telefon belgisini giriziň",
"weWillSendCode": "Biz size kod ugradarys"
"weWillSendCode": "Biz size kod ugradarys",
"loading": "Ýüklenýär..."
},
"category": "Bölümler",
"checkout": "Sargyt et",
@@ -85,5 +86,68 @@
"seller_application_form": "Dükan açmak üçin arza görnüşi",
"phone": "Telefon",
"unit_price": "1 san bahasy:",
"order_available_in_shops": "Dükanlarda sargyt bar:"
"order_available_in_shops": "Dükanlarda sargyt bar:",
"subcategories": "Kiçi bölümler",
"sort": "Tertiplemek",
"default": "Adaty",
"price_low_to_high": "Arzan bahadan gymmat bahara",
"price_high_to_low": "Gymmat bahadan arzan bahara",
"reset": "Arassalamak",
"total": "Jemi",
"no_results": "Netije tapylmady",
"close": "Ýap",
"category_not_found": "Bölüm tapylmady",
"empty_favorites": "Siziň saýlanan harytlaryňyz ýok",
"removed_from_favorites": "Haryt saýlanlardan aýryldy",
"added_to_cart": "Haryt sebede goşuldy",
"error": "Ýalňyşlyk ýüze çykdy",
"out_of_stock": "Haryt ýok",
"personal_info": "Şahsy maglumat",
"profile_description": "Siziň profil maglumatlaryňyz",
"error_loading_profile": "Profili ýükläp bolmady",
"try_again": "Täzeden synanyşyň",
"my_orders": "Meniň sargytlarym",
"active_orders": "Işjeň sargytlar",
"completed_orders": "Tamamlanan sargytlar",
"cancel_order": "Sargydy ýatyrmak",
"keep_order": "Sargydy saklamak",
"cancel_confirmation": "Siz bu sargydy ýatyrmagy hakykatdanam isleýärsiňizmi?",
"cancelling": "Ýatyrylýar...",
"order_number": "Sargyt №",
"no_orders": "Siziň heniz sargydyňyz ýok",
"no_active_orders": "Siziň işjeň sargydyňyz ýok",
"no_completed_orders": "Siziň tamamlanan sargydyňyz ýok",
"load_orders_error": "Sargytlary ýükläp bolmady",
"order_cancelled": "Sargyt ýatyryldy",
"order_cancelled_description": "Siziň sargydy üstünlikli ýatyryldy",
"cancel_order_failed": "Sargydy ýatyryp bolmady",
"delivery_time": "Eltip berme wagty",
"delivery_date": "Eltip berme senesi",
"payment_method": "Töleg usuly",
"product_not_found": "Haryt tapylmady",
"product_not_found_description": "Bu haryt ýok ýa-da aýryldy",
"no_image": "Surat ýok",
"stock": "Mukdary",
"barcode": "Barkod",
"product_description": "Haryt barada düşündiriş",
"adding": "Goşulýar...",
"added_to_cart_description": "sebede goşuldy",
"add_to_cart_failed": "Haryt sebede goşup bolmady",
"cart_updated": "Sebet täzelendi",
"update_quantity_failed": "Mukdar täzelenip bolmady",
"logging_out": "Çykylýar...",
"invalid_phone": "Nädogry telefon belgisi",
"invalid_code": "Nädogry kod",
"code_sent": "Kod siziň telefon belgiňize iberildi",
"login_success": "Giriş üstünlikli boldy",
"error_occurred": "Ýalňyşlyk ýüze çykdy",
"wrong_code": "Kod nädogry",
"phone_format": "Format: 99365123456",
"sending": "Iberilýär...",
"verifying": "Barlanýar...",
"verify": "Tassyklamak",
"only_left": "Sadece {count} adet kaldı",
"stock_limit_title": "Stok Limiti",
"stock_limit_message": "{product} ürününden sadece {stock} adet mevcut. Daha fazla ekleyemezsiniz.",
"understood": "Anladım"
}

View File

@@ -3,7 +3,7 @@ export * from "../../features/category/hooks/useCategories"
export * from "../../features/cart/hooks/useCart"
export * from "../../features/favorites/hooks/useFavorites"
export * from "../../features/orders/hooks/useOrders"
export * from "./useSearch"
export * from "../../features/search/hooks/useSearch"
export * from "../../features/profile/hooks/useUserProfile"
export * from "./useOpenStore"

View File

@@ -1,28 +0,0 @@
// import { useQuery } from "@tanstack/react-query"
// import { apiClient } from "@/lib/api"
// import type { SearchFilters, SearchResponse } from "@/lib/types/api"
// export function useSearch(options: SearchFilters) {
// const { q, category_id, brand_id, price_from, price_to, page = 1, per_page = 20 } = options
// return useQuery({
// queryKey: ["search", { q, category_id, brand_id, price_from, price_to, page, per_page }],
// queryFn: async () => {
// const params = new URLSearchParams({
// page: String(page),
// per_page: String(per_page),
// })
// // if (q) params.append("q", q)
// if (category_id) params.append("category_id", String(category_id))
// if (brand_id) params.append("brand_id", String(brand_id))
// if (price_from) params.append("price_from", String(price_from))
// if (price_to) params.append("price_to", String(price_to))
// const response = await apiClient.get<SearchResponse>(`/search?${params}`)
// return response.data
// },
// enabled: !!q && q.length > 0,
// staleTime: 1000 * 60 * 5,
// })
// }

View File

@@ -11,6 +11,14 @@ export interface ProductMedia {
images_1200x1200: string;
}
export type DeliveryType = "SELECTED_DELIVERY" | "PICK_UP";
export interface PaymentType {
id: number;
name: string;
code?: string;
}
export interface ProductProperty {
attribute_id: number;
name: string;
@@ -22,6 +30,22 @@ export interface ProductReviews {
rating: string;
}
export interface ProductBrand {
id: number | null;
name: string | null;
}
export interface ProductChannel {
id: number;
name: string;
}
export interface ProductCategory {
id: number;
name: string;
slug?: string;
}
export interface Product {
id: number;
parent_id: number | null;
@@ -47,22 +71,13 @@ export interface Product {
size: string | null;
available_colors?: string[];
available_sizes?: string[];
brand: {
id: number | null;
name: string | null;
};
channel?: Array<{
id: number;
name: string;
}>;
brand: ProductBrand;
channel?: ProductChannel[];
properties?: ProductProperty[];
variations?: any[];
reviews: ProductReviews;
reviews_resources?: any[];
categories?: Array<{
id: number;
name: string;
}>;
categories?: ProductCategory[];
}
// Category Types
@@ -71,8 +86,10 @@ export interface Category {
name: string;
slug: string;
image: string;
parent_id?: number;
parent_id?: number | null;
children?: Category[];
media:ProductMedia[];
}
// Collection Types
@@ -86,19 +103,43 @@ export interface Collection {
}
// Cart Types
export interface CartProduct {
id: number;
name: string;
slug: string;
price_amount: string;
old_price_amount: string | null;
media?: ProductMedia[];
channel?: ProductChannel[];
stock: number;
image?: string;
images?: string[];
}
export interface CartItem {
id: number;
product_id: number;
product?: Product;
seller: {
product: CartProduct;
product_quantity: number;
seller?: {
id: number;
name: string;
};
quantity: number;
price: number;
total: number;
price_formatted?: string;
sub_total_formatted?: string;
price_formatted: string;
sub_total_formatted: string;
total_formatted: string;
discount_formatted: string;
}
export interface CartResponse {
message?: string;
data: CartItem[];
count?: number;
total?: number;
total_formatted?: string;
}
export interface Cart {
@@ -111,33 +152,84 @@ export interface Cart {
// Favorites Types
export interface Favorite {
id: number;
id?: number;
product_id: number;
product?: Product;
product: Product;
added_at?: string;
created_at?: string;
}
// Order Types
export interface OrderItem {
export interface OrderProduct {
id: number;
product_id: number;
product?: Product;
name: string;
thumbnail: string;
images_400x400: string;
images_800x800: string;
images_1200x1200: string;
}
export interface OrderItem {
product: OrderProduct;
order: {
id: number;
};
quantity: number;
price: number;
total: number;
unit_price_amount: string;
}
export interface Order {
id: number;
number?: string;
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled";
items: OrderItem[];
total: number;
total_formatted?: string;
created_at: string;
updated_at?: string;
estimated_delivery?: string;
tracking_number?: string;
status: string;
shipping_method: string;
notes: string | null;
customer_name: string;
customer_phone: string;
customer_address: string;
delivery_time: string;
delivery_at: string;
region: string;
user_id: number;
province_id: number | null;
payment_type: string;
orderItems: OrderItem[];
}
export interface OrdersResponse {
message: string;
data: Order[];
pagination: {
page: number;
perPage: number;
count: number;
first_page_url: string;
next_page_url: string | null;
prev_page_url: string | null;
};
}
export interface CreateOrderRequest {
customer_name: string;
customer_phone: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}
export interface CreateOrderPayload {
customer_name?: string;
customer_phone?: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
}
// Pagination Types
@@ -152,6 +244,7 @@ export interface Pagination {
last_page?: number;
per_page?: number;
total?: number;
hasMorePages?: boolean;
}
export interface PaginatedResponse<T> {
@@ -181,15 +274,31 @@ export interface SearchResponse {
};
}
// Profile Types
// User Profile Types
export interface UserProfile {
id: number;
email: string;
phone?: string;
first_name: string;
last_name: string;
phone_number: string;
address: string;
email?: string;
}
export interface ProfileResponse {
message: string;
data: UserProfile;
}
export interface UpdateProfileRequest {
first_name?: string;
last_name?: string;
avatar?: string;
created_at: string;
phone_number?: string;
address?: string;
email?: string;
}
export interface UpdateProfileResponse {
message: string;
data: UserProfile;
}
// Auth Types
@@ -198,6 +307,26 @@ export interface AuthResponse {
user: UserProfile;
}
export interface LoginRequest {
phone_number: string;
}
export interface VerifyTokenRequest {
phone_number: string;
code: string;
}
export interface LoginResponse {
message: string;
token?: string;
}
export interface VerifyTokenResponse {
message: string;
token: string;
user: UserProfile;
}
// Banner Types
export interface Banner {
id: number;
@@ -210,19 +339,22 @@ export interface Banner {
place?: string;
}
// Generic API Error Response
export interface ApiError {
message: string;
errors?: Record<string, string[]>;
}
// Region, Address, PaymentType, and ShippingMethod Types
// Region and Province Types
export interface Region {
id: number;
code: string;
name: string;
region: string;
}
export interface Province {
id: number;
name: string;
region: string;
code?: string;
}
// Address Types
export interface Address {
id: number;
title: string;
@@ -232,27 +364,111 @@ export interface Address {
is_default?: boolean;
}
// Payment Type Options
export interface PaymentTypeOption {
id: number;
name: string;
code: string;
}
// Shipping Method Types
export interface ShippingMethod {
id: number;
name: string;
code: string;
}
// Order creation payload type
export interface CreateOrderPayload {
customer_name?: string;
customer_phone?: string;
customer_address: string;
shipping_method: string;
payment_type_id: number;
delivery_time?: string;
delivery_at?: string;
region: string;
note?: string;
// Generic API Error Response
export interface ApiError {
message: string;
errors?: Record<string, string[]>;
error?: string;
}
// API Response Wrapper
export interface ApiResponse<T = any> {
message?: string;
data?: T;
error?: string;
success?: boolean;
}
// Add to Cart Request
export interface AddToCartRequest {
productId: number;
quantity?: number;
}
// Update Cart Item Quantity Request
export interface UpdateCartItemQuantityRequest {
productId: number;
quantity: number;
}
// Remove from Cart Request
export interface RemoveFromCartRequest {
productId: number;
}
// Add to Favorites Request
export interface AddToFavoritesRequest {
productId: number;
}
// Remove from Favorites Request
export interface RemoveFromFavoritesRequest {
productId: number;
}
// Cancel Order Request
export interface CancelOrderRequest {
orderId: number;
}
// Order Summary for Cart Page
export interface OrderBillingItem {
title: string;
value: string;
}
export interface OrderBilling {
body: OrderBillingItem[];
footer: {
title: string;
value: string;
};
}
export interface OrderSummary {
id: number;
seller: {
id: number;
name: string;
};
items: CartItem[];
billing: OrderBilling;
}
// Category Products Response
export interface CategoryProductsResponse {
message?: string;
data: Product[];
pagination?: Pagination;
}
// Query Options for Hooks
export interface QueryOptions {
enabled?: boolean;
page?: number;
limit?: number;
refetchOnWindowFocus?: boolean;
refetchOnMount?: boolean;
staleTime?: number;
}
// User Store Data
export interface UserOrderData {
customer_name: string;
customer_phone: string;
customer_address?: string;
}